From ede189b76a0a927cfdf59e5d5d932982886fc5b3 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 11 Aug 2026 09:49:19 +0000 Subject: [PATCH 01/27] feat: render chat summaries as a headline plus bullets The whole-chat summary shown in the Summary tab rendered as a single prose blob. Three layers each independently prevented structure: the generation prompt banned all markdown, the normalizer collapsed every newline into a space, and the panel rendered the result into one plain

. Validation was also looser than the prompt asked, permitting up to 1000 runes and six sentences in a 448px column. Generation now returns a structured headline plus 2-4 bullets, which a shared serializer renders to markdown stored in the existing summary column. No migration is needed. Both summary producers go through formatChatSummaryMarkdown so the subagent path cannot bypass the format. Subagent summaries are extracted from an existing report rather than generated, so they pass no bullets and the serializer returns their snippet unchanged. Validation moved onto the struct, before serialization. Applying the old sentence cap to serialized markdown would have silently stopped working, since bullets routinely omit trailing punctuation. Per-field normalization also preserves backticks, which the shared normalizeShortTextOutput strips, so a headline ending in an inline code span keeps a balanced pair. The panel renders the markdown through InlineMarkdown with a narrow allowlist, bounded to max-h-48 with a Show more toggle. Legacy prose summaries continue to render; summaries regenerate after three completed turns, so both shapes coexist without a backfill. --- coderd/x/chatd/chatd.go | 5 +- coderd/x/chatd/quickgen.go | 116 +++++++++-- coderd/x/chatd/summarygen_internal_test.go | 190 +++++++++++++++++- .../components/ChatSummary.stories.tsx | 90 ++++++++- .../AgentsPage/components/ChatSummary.tsx | 108 +++++++++- .../components/ChatSummaryPanel.stories.tsx | 13 +- 6 files changed, 485 insertions(+), 37 deletions(-) diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 9d37df099673a..4b05aa3acffd8 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -4948,7 +4948,10 @@ func (p *Server) storeSubagentReportSummary( slog.F("chat_id", chat.ID), slog.Error(err)) return } - summary := subagentReportSummarySnippet(report) + // Subagent summaries are extracted from an existing report rather than + // generated, so they have no bullets; the serializer keeps the headline + // as-is and only adds structure when bullets exist. + summary := formatChatSummaryMarkdown(subagentReportSummarySnippet(report), nil) if summary == "" { return } diff --git a/coderd/x/chatd/quickgen.go b/coderd/x/chatd/quickgen.go index ccc73b3ed30d6..ce8d5e3b69f46 100644 --- a/coderd/x/chatd/quickgen.go +++ b/coderd/x/chatd/quickgen.go @@ -969,11 +969,13 @@ func generateManualTitle( } const chatSummaryGenerationPrompt = "You summarize an AI coding chat for a quick-reference popover. " + - "Populate the summary field with 1 to 3 plain sentences describing what the conversation is about and what was accomplished or attempted. " + + "Populate the headline field with one sentence naming what the conversation is about and its outcome. " + + "Populate the bullets field with 2 to 4 short bullets covering what was done or attempted, each a single line. " + "Write about the conversation in the third person. " + - "Preserve specific identifiers such as PR numbers, repo names, file paths, function names, and error messages. " + + "Preserve specific identifiers such as PR numbers, repo names, file paths, function names, and error messages, " + + "wrapping them in backticks. " + "Do not address the user, give instructions, or continue the task. " + - "No markdown, lists, headings, code fences, or surrounding quotes." + "No headings, code fences, tables, or nested lists." const ( // Bound the transcript so the summary call stays cheap and within context; @@ -982,14 +984,20 @@ const ( // Cap a single turn so one long message cannot dominate the budget. summaryTranscriptPerMessageMaxRunes = 4000 summaryMaxOutputTokens = 512 - // Reject pathologically long or verbose summaries, with slack over the - // 1-3 sentence target. - summaryMaxRunes = 1000 - summaryMaxSentences = 6 + // Reject pathologically long or verbose summaries. The caps apply to the + // structured fields before serialization, plus a ceiling on the rendered + // markdown so the panel stays scannable. + summaryMaxRunes = 600 + summaryHeadlineMaxRunes = 200 + summaryHeadlineMaxSentences = 2 + summaryBulletMaxRunes = 160 + summaryMinBullets = 2 + summaryMaxBullets = 4 ) type generatedChatSummary struct { - Summary string `json:"summary" description:"1-3 sentence summary of the whole chat"` + Headline string `json:"headline" description:"One sentence naming what the chat is about and its outcome"` + Bullets []string `json:"bullets" description:"2-4 short bullets, each one line, covering what was done or attempted"` } // renderChatSummaryTranscript renders chat history as plain text for summary @@ -1123,7 +1131,7 @@ func generateChatSummary( result, genErr = object.Generate[generatedChatSummary](retryCtx, model, fantasy.ObjectCall{ Prompt: prompt, SchemaName: "chat_summary", - SchemaDescription: "Summarize the whole chat in 1-3 sentences.", + SchemaDescription: "Summarize the whole chat as a one-sentence headline plus 2-4 short bullets.", MaxOutputTokens: &maxOutputTokens, }) return genErr @@ -1136,22 +1144,94 @@ func generateChatSummary( return "", usage, xerrors.Errorf("generate chat summary: %w", err) } - summary := normalizeShortTextOutput(result.Object.Summary) + summary := generatedChatSummary{ + Headline: normalizeSummaryField(result.Object.Headline), + Bullets: normalizeSummaryBullets(result.Object.Bullets), + } if err := validateGeneratedChatSummary(summary); err != nil { return "", result.Usage, err } - return summary, result.Usage, nil + return formatChatSummaryMarkdown(summary.Headline, summary.Bullets), result.Usage, nil } -func validateGeneratedChatSummary(summary string) error { - if summary == "" { - return xerrors.New("generated chat summary was empty") +// normalizeSummaryField collapses internal whitespace so a field stays on one +// line, and strips surrounding quotes. Unlike normalizeShortTextOutput it +// preserves backticks, so a field ending in an inline code span keeps a +// balanced pair. +func normalizeSummaryField(text string) string { + text = strings.TrimSpace(text) + if text == "" { + return "" } - if len([]rune(summary)) > summaryMaxRunes { - return xerrors.Errorf("generated chat summary exceeded %d runes", summaryMaxRunes) + + text = strings.Trim(text, "\"'") + return strings.Join(strings.Fields(text), " ") +} + +// normalizeSummaryBullets normalizes each bullet and drops the ones that +// normalize to nothing, so blank model output does not render as an empty +// list item. +func normalizeSummaryBullets(bullets []string) []string { + normalized := make([]string, 0, len(bullets)) + for _, bullet := range bullets { + if bullet = normalizeSummaryField(bullet); bullet != "" { + normalized = append(normalized, bullet) + } } - if countSentenceTerminators(summary) > summaryMaxSentences { - return xerrors.Errorf("generated chat summary exceeded %d sentences", summaryMaxSentences) + return normalized +} + +// formatChatSummaryMarkdown renders the stored summary as a headline paragraph +// followed by an optional bullet list, separated by a blank line. Both summary +// producers go through here so the stored format stays consistent. +func formatChatSummaryMarkdown(headline string, bullets []string) string { + headline = strings.TrimSpace(headline) + if len(bullets) == 0 { + return headline + } + + var out strings.Builder + _, _ = out.WriteString(headline) + _, _ = out.WriteString("\n") + for _, bullet := range bullets { + if bullet = strings.TrimSpace(bullet); bullet != "" { + _, _ = out.WriteString("\n- ") + _, _ = out.WriteString(bullet) + } + } + return strings.TrimSpace(out.String()) +} + +// validateGeneratedChatSummary checks the structured fields before they are +// serialized. Validating here rather than over the rendered markdown keeps the +// sentence cap meaningful: bullets routinely omit trailing punctuation, so a +// sentence count over the serialized string would pass almost anything. +func validateGeneratedChatSummary(summary generatedChatSummary) error { + if summary.Headline == "" { + return xerrors.New("generated chat summary headline was empty") + } + if len([]rune(summary.Headline)) > summaryHeadlineMaxRunes { + return xerrors.Errorf("generated chat summary headline exceeded %d runes", summaryHeadlineMaxRunes) + } + if countSentenceTerminators(summary.Headline) > summaryHeadlineMaxSentences { + return xerrors.Errorf("generated chat summary headline exceeded %d sentences", summaryHeadlineMaxSentences) + } + if len(summary.Bullets) < summaryMinBullets || len(summary.Bullets) > summaryMaxBullets { + return xerrors.Errorf( + "generated chat summary had %d bullets, want %d to %d", + len(summary.Bullets), summaryMinBullets, summaryMaxBullets, + ) + } + for _, bullet := range summary.Bullets { + if len([]rune(bullet)) > summaryBulletMaxRunes { + return xerrors.Errorf("generated chat summary bullet exceeded %d runes", summaryBulletMaxRunes) + } + if strings.ContainsAny(bullet, "\n\r") { + return xerrors.New("generated chat summary bullet contained a newline") + } + } + if rendered := formatChatSummaryMarkdown(summary.Headline, summary.Bullets); len([]rune(rendered)) > summaryMaxRunes { + return xerrors.Errorf("generated chat summary exceeded %d runes", summaryMaxRunes) } return nil } diff --git a/coderd/x/chatd/summarygen_internal_test.go b/coderd/x/chatd/summarygen_internal_test.go index 824dbb3c6db4d..0a1908228b52a 100644 --- a/coderd/x/chatd/summarygen_internal_test.go +++ b/coderd/x/chatd/summarygen_internal_test.go @@ -225,10 +225,98 @@ func TestShouldGenerateChatSummary(t *testing.T) { func TestValidateGeneratedChatSummary(t *testing.T) { t.Parallel() - require.Error(t, validateGeneratedChatSummary("")) - require.Error(t, validateGeneratedChatSummary(strings.Repeat("a", summaryMaxRunes+1))) - require.Error(t, validateGeneratedChatSummary("One. Two. Three. Four. Five. Six. Seven.")) - require.NoError(t, validateGeneratedChatSummary("Implemented the summary feature. Added tests.")) + validBullets := []string{"Traced the race in `cache.go`", "Added a regression test"} + + tests := []struct { + name string + summary generatedChatSummary + wantErr bool + }{ + { + name: "Valid", + summary: generatedChatSummary{Headline: "Fixed the flaky CI job.", Bullets: validBullets}, + }, + { + name: "EmptyHeadline", + summary: generatedChatSummary{Bullets: validBullets}, + wantErr: true, + }, + { + name: "HeadlineTooLong", + summary: generatedChatSummary{ + Headline: strings.Repeat("a", summaryHeadlineMaxRunes+1), + Bullets: validBullets, + }, + wantErr: true, + }, + { + name: "HeadlineTooManySentences", + summary: generatedChatSummary{ + Headline: "One. Two. Three.", + Bullets: validBullets, + }, + wantErr: true, + }, + { + name: "TooFewBullets", + summary: generatedChatSummary{Headline: "Fixed it.", Bullets: []string{"Only one"}}, + wantErr: true, + }, + { + name: "NoBullets", + summary: generatedChatSummary{Headline: "Fixed it."}, + wantErr: true, + }, + { + name: "TooManyBullets", + summary: generatedChatSummary{ + Headline: "Fixed it.", + Bullets: []string{"One", "Two", "Three", "Four", "Five"}, + }, + wantErr: true, + }, + { + name: "BulletTooLong", + summary: generatedChatSummary{ + Headline: "Fixed it.", + Bullets: []string{"Fine", strings.Repeat("b", summaryBulletMaxRunes+1)}, + }, + wantErr: true, + }, + { + name: "BulletWithNewline", + summary: generatedChatSummary{ + Headline: "Fixed it.", + Bullets: []string{"Fine", "Broken\nacross lines"}, + }, + wantErr: true, + }, + { + name: "SerializedTooLong", + summary: generatedChatSummary{ + Headline: strings.Repeat("a", summaryHeadlineMaxRunes), + Bullets: []string{ + strings.Repeat("b", summaryBulletMaxRunes), + strings.Repeat("c", summaryBulletMaxRunes), + strings.Repeat("d", summaryBulletMaxRunes), + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := validateGeneratedChatSummary(tt.summary) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } } func TestCountSentenceTerminators(t *testing.T) { @@ -239,10 +327,96 @@ func TestCountSentenceTerminators(t *testing.T) { require.Equal(t, 3, countSentenceTerminators("One. Two! Three?")) require.Equal(t, 0, countSentenceTerminators("auth.rbac.Policy")) - // Dotted identifiers must not push a valid summary over the sentence cap. - require.NoError(t, validateGeneratedChatSummary( - "Refactored pkg.cmd.server and auth.rbac.Policy in main.go and util.go. Added coverage in foo_test.go.", - )) + // Dotted identifiers must not push a valid headline over the sentence cap. + require.NoError(t, validateGeneratedChatSummary(generatedChatSummary{ + Headline: "Refactored pkg.cmd.server and auth.rbac.Policy in main.go and util.go.", + Bullets: []string{"Updated call sites", "Added coverage in foo_test.go"}, + })) +} + +func TestNormalizeSummaryField(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + text string + want string + }{ + {name: "Empty", text: " ", want: ""}, + {name: "CollapsesNewlines", text: "Fixed the race\nin cache.go", want: "Fixed the race in cache.go"}, + {name: "CollapsesRuns", text: "Fixed the\t\trace", want: "Fixed the race"}, + {name: "StripsSurroundingQuotes", text: `"Fixed the race"`, want: "Fixed the race"}, + { + // normalizeShortTextOutput would strip this trailing backtick and + // leave an unbalanced inline code span. + name: "PreservesTrailingBacktick", + text: "Fixed `cache.go`", + want: "Fixed `cache.go`", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + require.Equal(t, tt.want, normalizeSummaryField(tt.text)) + }) + } +} + +func TestNormalizeSummaryBullets(t *testing.T) { + t.Parallel() + + require.Equal(t, + []string{"First bullet", "Second bullet"}, + normalizeSummaryBullets([]string{" First\nbullet ", " ", "Second bullet", ""}), + ) + require.Empty(t, normalizeSummaryBullets(nil)) +} + +func TestFormatChatSummaryMarkdown(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + headline string + bullets []string + want string + }{ + { + name: "HeadlineOnly", + headline: "Fixed the flaky CI job.", + want: "Fixed the flaky CI job.", + }, + { + // A blank line must separate the paragraph from the list, or + // CommonMark folds the first bullet into the headline paragraph. + name: "HeadlineAndBullets", + headline: "Fixed the flaky CI job.", + bullets: []string{"Traced the race", "Added a test"}, + want: "Fixed the flaky CI job.\n\n- Traced the race\n- Added a test", + }, + { + name: "DropsEmptyBullets", + headline: "Fixed it.", + bullets: []string{"Kept", " ", "Also kept"}, + want: "Fixed it.\n\n- Kept\n- Also kept", + }, + { + name: "AllBulletsEmptyKeepsHeadline", + headline: "Fixed it.", + bullets: []string{" ", ""}, + want: "Fixed it.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + require.Equal(t, tt.want, formatChatSummaryMarkdown(tt.headline, tt.bullets)) + }) + } } func TestSubagentReportSummarySnippet(t *testing.T) { diff --git a/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx b/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx index d680df898e7b0..cb3afd0617c2b 100644 --- a/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx @@ -1,13 +1,20 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, within } from "storybook/test"; +import { expect, userEvent, waitFor, within } from "storybook/test"; import { ChatSummary } from "./ChatSummary"; +const MARKDOWN_SUMMARY = [ + "Investigated the flaky CI job in `coderd/x/chatd` and landed a fix.", + "", + "- Traced the failure to a cache-layer race in `chatd.go`", + "- Added a regression test covering the race", + "- Opened PR #26649", +].join("\n"); + const meta: Meta = { title: "pages/AgentsPage/ChatSummary", component: ChatSummary, args: { - summary: - "Investigated the flaky CI job, traced it to a race in the cache layer, and added a regression test.", + summary: MARKDOWN_SUMMARY, createdAt: "2024-05-01T12:00:00Z", updatedAt: "2024-05-02T15:30:00Z", costMicros: 1_250_000, @@ -39,6 +46,83 @@ export const WithSummary: Story = { }, }; +export const HeadlineAndBullets: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByText(/Investigated the flaky CI job/), + ).toBeInTheDocument(); + + const list = canvas.getByRole("list"); + await expect(within(list).getAllByRole("listitem")).toHaveLength(3); + + // Identifiers wrapped in backticks render as inline code, not literal + // backticks. + await expect(canvas.getByText("chatd.go")).toBeInTheDocument(); + await expect(canvas.queryByText(/`/)).not.toBeInTheDocument(); + + // Short content fits the bound, so no toggle is offered. + await expect( + canvas.queryByRole("button", { name: "Show more" }), + ).not.toBeInTheDocument(); + }, +}; + +// Summaries generated before the structured format are plain prose. They must +// still render, since there is no backfill. +export const LegacyProseSummary: Story = { + args: { + summary: + "Investigated the flaky CI job, traced it to a race in the cache layer, and added a regression test.", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByText(/traced it to a race in the cache layer/), + ).toBeInTheDocument(); + await expect(canvas.queryByRole("list")).not.toBeInTheDocument(); + }, +}; + +// A legacy prose summary that happens to start with "1. " parses as an ordered +// list. `ol` is allowlisted so the items keep a list parent instead of +// rendering as orphan `li` elements. +export const LegacyOrderedList: Story = { + args: { summary: "1. Fixed the race\n2. Added a test" }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const list = canvas.getByRole("list"); + await expect(list.tagName).toBe("OL"); + await expect(within(list).getAllByRole("listitem")).toHaveLength(2); + }, +}; + +export const LongSummaryExpands: Story = { + args: { + summary: [ + "Audited the whole chat pipeline and shipped a batch of fixes.", + "", + ...Array.from( + { length: 12 }, + (_, i) => + `- Reviewed subsystem number ${i + 1} and applied the corresponding fix so the behaviour matches the specification`, + ), + ].join("\n"), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const showMore = await canvas.findByRole("button", { name: "Show more" }); + await userEvent.click(showMore); + + await waitFor(async () => { + await expect( + canvas.getByRole("button", { name: "Show less" }), + ).toBeInTheDocument(); + }); + }, +}; + export const NoSummary: Story = { args: { summary: null }, play: async ({ canvasElement }) => { diff --git a/site/src/pages/AgentsPage/components/ChatSummary.tsx b/site/src/pages/AgentsPage/components/ChatSummary.tsx index 41ca7328bcf04..5a3df60656e12 100644 --- a/site/src/pages/AgentsPage/components/ChatSummary.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummary.tsx @@ -1,10 +1,21 @@ -import type { FC, ReactNode } from "react"; +import { + type FC, + type ReactNode, + useCallback, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { InlineMarkdown } from "#/components/Markdown/InlineMarkdown"; import { Skeleton } from "#/components/Skeleton/Skeleton"; import { formatCostMicros } from "#/utils/currency"; import { DATE_FORMAT, formatDateTime } from "#/utils/time"; const EMPTY_VALUE = "-"; +/** Compact list spacing that keeps markers inside the narrow summary column. */ +const LIST_CLASSES = "my-2 flex flex-col gap-1 pl-5"; + interface ChatSummaryProps { summary: string | null; createdAt: string; @@ -40,9 +51,7 @@ export const ChatSummary: FC = ({ return (

{trimmedSummary ? ( -

- {trimmedSummary} -

+ ) : (

{isSubagent ? "Summary pending agent completion." : "No summary yet."} @@ -88,6 +97,97 @@ export const ChatSummary: FC = ({ ); }; +interface ChatSummaryBodyProps { + summary: string; +} + +/** + * Renders the stored summary markdown (a headline paragraph plus an optional + * bullet list) inside a bounded box, revealing a toggle only when the content + * actually overflows. `max-height` is used instead of `line-clamp` because + * `line-clamp` relies on `display: -webkit-box`, which clamps unreliably once + * the content contains nested block children such as `

  • `. + */ +const ChatSummaryBody: FC = ({ summary }) => { + const contentRef = useRef(null); + const [isExpanded, setIsExpanded] = useState(false); + const [isOverflowing, setIsOverflowing] = useState(false); + + const measure = useCallback(() => { + const content = contentRef.current; + if (!content) { + return; + } + // Measure against the collapsed bound, which only applies while + // collapsed; once expanded the box grows and would always measure as + // fitting, hiding the "Show less" affordance. + setIsOverflowing(content.scrollHeight > content.clientHeight); + }, []); + + useLayoutEffect(() => { + if (isExpanded) { + return; + } + measure(); + + const content = contentRef.current; + if (!content || typeof ResizeObserver === "undefined") { + return; + } + // The right panel is resizable, and the observer fires immediately on + // observe(), so this also covers a summary swap that changes the box + // height. A swap that leaves the height pinned to the clamp cannot + // change the overflow verdict, so no summary dependency is needed. + const observer = new ResizeObserver(measure); + observer.observe(content); + return () => observer.disconnect(); + }, [measure, isExpanded]); + + return ( +
    +
    +

    {children}

    , + ul: ({ children }) => ( +
      {children}
    + ), + ol: ({ children }) => ( +
      {children}
    + ), + li: ({ children }) => ( +
  • {children}
  • + ), + }} + > + {summary} + +
+ + {(isOverflowing || isExpanded) && ( + + )} + + ); +}; + interface ChatSummaryRowProps { label: string; children: ReactNode; diff --git a/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx b/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx index 896ee03d98fd8..e6fb51621ae6a 100644 --- a/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx @@ -71,17 +71,24 @@ type Story = StoryObj; export const WithSummary: Story = { beforeEach: () => mockRequests({ - summary: - "Investigated the flaky CI job, traced it to a cache-layer race, and added a regression test.", + summary: [ + "Investigated the flaky CI job and landed a fix.", + "", + "- Traced it to a cache-layer race in `chatd.go`", + "- Added a regression test covering the race", + ].join("\n"), }), play: async ({ canvasElement }) => { const canvas = within(canvasElement); await waitFor(() => { expect( - canvas.getByText(/traced it to a cache-layer race/), + canvas.getByText(/Traced it to a cache-layer race/), ).toBeInTheDocument(); expect(canvas.getByText("$1.25")).toBeInTheDocument(); }); + expect( + within(canvas.getByRole("list")).getAllByRole("listitem"), + ).toHaveLength(2); }, }; From 634f89079ae5141fe06efb37ac727d8643d354da Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 11 Aug 2026 10:16:48 +0000 Subject: [PATCH 02/27] fix(site/src/pages/AgentsPage/components): address summary panel review Observe overflow on an inner unclamped element rather than the clamped box. The clamped box stops growing at its max height, so a summary that arrives via an in-place cache update while the box is already pinned there resized nothing and stayed clipped with no toggle. Drop useCallback, which site/AGENTS.md prohibits under src/pages/AgentsPage since React Compiler already memoizes callbacks there. The measurement closure now lives inside the effect. Render link text without an anchor. Clipping below the collapsed bound is visual only, so a mounted anchor stayed reachable by keyboard and screen readers while invisible. --- .../components/ChatSummary.stories.tsx | 68 +++++++++++-- .../AgentsPage/components/ChatSummary.tsx | 96 +++++++++++-------- 2 files changed, 112 insertions(+), 52 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx b/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx index cb3afd0617c2b..8b56effbde3c0 100644 --- a/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; import { expect, userEvent, waitFor, within } from "storybook/test"; import { ChatSummary } from "./ChatSummary"; @@ -10,6 +11,16 @@ const MARKDOWN_SUMMARY = [ "- Opened PR #26649", ].join("\n"); +const LONG_SUMMARY = [ + "Audited the whole chat pipeline and shipped a batch of fixes.", + "", + ...Array.from( + { length: 12 }, + (_, i) => + `- Reviewed subsystem number ${i + 1} and applied the corresponding fix so the behaviour matches the specification`, + ), +].join("\n"); + const meta: Meta = { title: "pages/AgentsPage/ChatSummary", component: ChatSummary, @@ -97,18 +108,55 @@ export const LegacyOrderedList: Story = { }, }; -export const LongSummaryExpands: Story = { +// Links are rendered as plain text. `overflow-hidden` clips the collapsed +// content visually only, so a mounted anchor below the bound would still be +// reachable by keyboard and screen readers while invisible. +export const LinksRenderAsPlainText: Story = { args: { - summary: [ - "Audited the whole chat pipeline and shipped a batch of fixes.", - "", - ...Array.from( - { length: 12 }, - (_, i) => - `- Reviewed subsystem number ${i + 1} and applied the corresponding fix so the behaviour matches the specification`, - ), - ].join("\n"), + summary: + "Investigated the failure in [PR #26649](https://example.com/pr) and fixed it.", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(/PR #26649/)).toBeInTheDocument(); + await expect(canvas.queryByRole("link")).not.toBeInTheDocument(); }, +}; + +// A cache update can replace the summary in place, without remounting the +// panel, so the overflow toggle has to re-evaluate on new content. +export const SummaryReplacedInPlace: Story = { + render: (args) => { + const [summary, setSummary] = useState(MARKDOWN_SUMMARY); + return ( +
+ + +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.queryByRole("button", { name: "Show more" }), + ).not.toBeInTheDocument(); + + await userEvent.click( + canvas.getByRole("button", { name: "Simulate update" }), + ); + + await waitFor(async () => { + await expect( + canvas.getByRole("button", { name: "Show more" }), + ).toBeInTheDocument(); + }); + }, +}; + +export const LongSummaryExpands: Story = { + args: { summary: LONG_SUMMARY }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); diff --git a/site/src/pages/AgentsPage/components/ChatSummary.tsx b/site/src/pages/AgentsPage/components/ChatSummary.tsx index 5a3df60656e12..27b06c245e5e6 100644 --- a/site/src/pages/AgentsPage/components/ChatSummary.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummary.tsx @@ -1,7 +1,6 @@ import { type FC, type ReactNode, - useCallback, useLayoutEffect, useRef, useState, @@ -107,72 +106,85 @@ interface ChatSummaryBodyProps { * actually overflows. `max-height` is used instead of `line-clamp` because * `line-clamp` relies on `display: -webkit-box`, which clamps unreliably once * the content contains nested block children such as `
  • `. + * + * Overflow is measured on the clamped box but observed on an inner unclamped + * element, so both panel resizes and in-place summary updates re-evaluate the + * toggle. */ const ChatSummaryBody: FC = ({ summary }) => { + const clampRef = useRef(null); const contentRef = useRef(null); const [isExpanded, setIsExpanded] = useState(false); const [isOverflowing, setIsOverflowing] = useState(false); - const measure = useCallback(() => { - const content = contentRef.current; - if (!content) { - return; - } - // Measure against the collapsed bound, which only applies while - // collapsed; once expanded the box grows and would always measure as - // fitting, hiding the "Show less" affordance. - setIsOverflowing(content.scrollHeight > content.clientHeight); - }, []); - useLayoutEffect(() => { + // Overflow only needs measuring while collapsed. Skipping the expanded + // state preserves the verdict computed while collapsed, so the toggle + // stays visible; collapsing reruns this effect and remeasures. if (isExpanded) { return; } + const clamp = clampRef.current; + const content = contentRef.current; + if (!clamp || !content) { + return; + } + const measure = () => + setIsOverflowing(clamp.scrollHeight > clamp.clientHeight); measure(); - const content = contentRef.current; - if (!content || typeof ResizeObserver === "undefined") { + if (typeof ResizeObserver === "undefined") { return; } - // The right panel is resizable, and the observer fires immediately on - // observe(), so this also covers a summary swap that changes the box - // height. A swap that leaves the height pinned to the clamp cannot - // change the overflow verdict, so no summary dependency is needed. + // Observe the inner element rather than the clamped one. The clamped box + // stops growing at its max height, so a summary that arrives via a cache + // update while the box is already pinned there would resize nothing and + // stay clipped with no toggle. The inner element is unclamped, so its + // height tracks the content and its width tracks the resizable panel. const observer = new ResizeObserver(measure); observer.observe(content); return () => observer.disconnect(); - }, [measure, isExpanded]); + }, [isExpanded]); return (
    -

    {children}

    , - ul: ({ children }) => ( -
      {children}
    - ), - ol: ({ children }) => ( -
      {children}
    - ), - li: ({ children }) => ( -
  • {children}
  • - ), - }} - > - {summary} - +
    + ( +

    {children}

    + ), + ul: ({ children }) => ( +
      {children}
    + ), + ol: ({ children }) => ( +
      {children}
    + ), + li: ({ children }) => ( +
  • {children}
  • + ), + // Render link text without an anchor. Clipping below the + // collapsed bound is visual only, so a mounted anchor would + // stay reachable by keyboard and screen readers while + // invisible. Summaries are generated text, not navigation. + a: ({ children }) => <>{children}, + }} + > + {summary} +
    +
    {(isOverflowing || isExpanded) && ( From dff5f061b7f1db9a3834411c592c5f6837ba30cf Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 11 Aug 2026 10:24:17 +0000 Subject: [PATCH 03/27] docs(coderd/x/chatd): correct stale chat summary format comments generateChatSummary now returns a headline plus bullets, but its doc comment still described a 1-3 sentence summary. The subagent snippet bound in chatd.go referenced the same obsolete contract. --- coderd/x/chatd/chatd.go | 2 +- coderd/x/chatd/quickgen.go | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 4b05aa3acffd8..284ae56abfcf0 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -4726,7 +4726,7 @@ const ( // Subagent summaries reuse the final report instead of generating // text, so their work timeout only covers two database round trips. subagentReportSummaryTimeout = 15 * time.Second - // Bound the extracted report snippet near the 1-3 sentence + // Bound the extracted report snippet near the headline of the // generated summaries that root chats get, so subagent and parent // summary panels read the same. subagentReportSummaryMaxRunes = 300 diff --git a/coderd/x/chatd/quickgen.go b/coderd/x/chatd/quickgen.go index ce8d5e3b69f46..debd2f5f917d8 100644 --- a/coderd/x/chatd/quickgen.go +++ b/coderd/x/chatd/quickgen.go @@ -1096,9 +1096,10 @@ func boundTranscriptHeadTail(lines []string, maxRunes int) string { return out.String() } -// generateChatSummary generates a 1-3 sentence whole-chat summary from a -// transcript. A blank or invalid result returns an error so callers preserve -// any existing summary rather than clearing it. +// generateChatSummary generates a whole-chat summary from a transcript as a +// one-sentence headline plus 2-4 bullets, serialized to markdown by +// formatChatSummaryMarkdown. A blank or invalid result returns an error so +// callers preserve any existing summary rather than clearing it. func generateChatSummary( ctx context.Context, model fantasy.LanguageModel, From 9af00845474c96d1797d113c4ab31d6dad887998 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 11 Aug 2026 10:35:47 +0000 Subject: [PATCH 04/27] fix(site/src/pages/AgentsPage/components): wrap long identifiers in summaries Summaries preserve identifiers verbatim and wrap them in backticks, so a single token can be wider than the panel with no natural break opportunity. The summary box sized itself to that token, escaping its column, and the clipped remainder was unreachable because the toggle only responds to vertical overflow. --- .../components/ChatSummary.stories.tsx | 33 +++++++++++++++++++ .../AgentsPage/components/ChatSummary.tsx | 6 +++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx b/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx index 8b56effbde3c0..f13057ce0c495 100644 --- a/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx @@ -123,6 +123,39 @@ export const LinksRenderAsPlainText: Story = { }, }; +// The generation prompt preserves identifiers and wraps them in backticks, so +// a single token can be wider than the panel. It has to wrap, because the +// collapsed bound only reveals its toggle for vertical overflow. +export const LongIdentifierWraps: Story = { + args: { + summary: + "Fixed `TestValidateGeneratedChatSummaryHeadlineExceedsBothTheRuneAndSentenceCaps` in `coderd/x/chatd/summarygen_internal_test.go`.", + }, + // Narrower than the panel's 360px minimum, so the identifier cannot fit on + // one line. + decorators: [ + (Story) => ( +
    + +
    + ), + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const column = canvas.getByTestId("summary-column"); + const identifier = canvas.getByText( + "TestValidateGeneratedChatSummaryHeadlineExceedsBothTheRuneAndSentenceCaps", + ); + + // The identifier has no natural break opportunity, so without a + // word-breaking rule it renders on one line and escapes the column, + // where overflow-hidden clips it with no toggle to reveal it. + await expect(identifier.getBoundingClientRect().right).toBeLessThanOrEqual( + column.getBoundingClientRect().right + 1, + ); + }, +}; + // A cache update can replace the summary in place, without remounting the // panel, so the overflow toggle has to re-evaluate on new content. export const SummaryReplacedInPlace: Story = { diff --git a/site/src/pages/AgentsPage/components/ChatSummary.tsx b/site/src/pages/AgentsPage/components/ChatSummary.tsx index 27b06c245e5e6..2bcae26f83a9c 100644 --- a/site/src/pages/AgentsPage/components/ChatSummary.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummary.tsx @@ -150,7 +150,11 @@ const ChatSummaryBody: FC = ({ summary }) => {
    From 90f83d6752a634fc987c94348577000900842458 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 11 Aug 2026 10:42:48 +0000 Subject: [PATCH 05/27] test(site/src/pages/AgentsPage/components): drop geometry assertion from summary story FE10 prohibits DOM-geometry assertions because pixel values vary with font rendering across environments. The long-identifier story keeps its narrow column as a visual regression fixture and asserts semantically instead. --- .../components/ChatSummary.stories.tsx | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx b/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx index f13057ce0c495..c31ebd19822df 100644 --- a/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx @@ -131,28 +131,24 @@ export const LongIdentifierWraps: Story = { summary: "Fixed `TestValidateGeneratedChatSummaryHeadlineExceedsBothTheRuneAndSentenceCaps` in `coderd/x/chatd/summarygen_internal_test.go`.", }, - // Narrower than the panel's 360px minimum, so the identifier cannot fit on - // one line. + // Pinned narrower than the panel's 360px minimum so the identifier cannot + // fit on one line. The wrapped layout itself is covered by visual + // regression snapshots; per FE10 the assertion here stays semantic rather + // than measuring geometry. decorators: [ (Story) => ( -
    +
    ), ], play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const column = canvas.getByTestId("summary-column"); - const identifier = canvas.getByText( - "TestValidateGeneratedChatSummaryHeadlineExceedsBothTheRuneAndSentenceCaps", - ); - - // The identifier has no natural break opportunity, so without a - // word-breaking rule it renders on one line and escapes the column, - // where overflow-hidden clips it with no toggle to reveal it. - await expect(identifier.getBoundingClientRect().right).toBeLessThanOrEqual( - column.getBoundingClientRect().right + 1, - ); + await expect( + canvas.getByText( + "TestValidateGeneratedChatSummaryHeadlineExceedsBothTheRuneAndSentenceCaps", + ), + ).toBeVisible(); }, }; From 32cd37b6e010d8b9f9c5c3b4578618649645a34a Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 11 Aug 2026 10:55:00 +0000 Subject: [PATCH 06/27] perf(site/src/pages/AgentsPage/components): settle summary resize measurement Dragging the right panel resized the summary every frame, and each observer callback read layout and set state even though the overflow verdict is a boolean that flips at most a couple of times per drag. Waiting for the resize to settle skips those reads entirely while a drag is in flight. --- .../AgentsPage/components/ChatSummary.tsx | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatSummary.tsx b/site/src/pages/AgentsPage/components/ChatSummary.tsx index 2bcae26f83a9c..121620853b380 100644 --- a/site/src/pages/AgentsPage/components/ChatSummary.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummary.tsx @@ -15,6 +15,13 @@ const EMPTY_VALUE = "-"; /** Compact list spacing that keeps markers inside the narrow summary column. */ const LIST_CLASSES = "my-2 flex flex-col gap-1 pl-5"; +/** + * How long resizing must settle before overflow is remeasured. Dragging the + * right panel resizes the summary every frame, and the overflow verdict is a + * boolean that flips at most a couple of times per drag. + */ +const RESIZE_SETTLE_MS = 100; + interface ChatSummaryProps { summary: string | null; createdAt: string; @@ -141,9 +148,21 @@ const ChatSummaryBody: FC = ({ summary }) => { // update while the box is already pinned there would resize nothing and // stay clipped with no toggle. The inner element is unclamped, so its // height tracks the content and its width tracks the resizable panel. - const observer = new ResizeObserver(measure); + // + // Debounce rather than schedule on an animation frame: observer + // callbacks are already delivered at most once per frame, so a frame + // callback would defer the same work instead of doing less of it. + // Settling skips the layout reads entirely while a drag is in flight. + let settleTimeout: ReturnType | undefined; + const observer = new ResizeObserver(() => { + clearTimeout(settleTimeout); + settleTimeout = setTimeout(measure, RESIZE_SETTLE_MS); + }); observer.observe(content); - return () => observer.disconnect(); + return () => { + clearTimeout(settleTimeout); + observer.disconnect(); + }; }, [isExpanded]); return ( From 1b6328dccf944fc63a9b9f9b14c394ea6f95fc52 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 11 Aug 2026 11:06:46 +0000 Subject: [PATCH 07/27] fix(site/src/pages/AgentsPage/components): reset summary expansion per chat Switching chats swaps the panel's props instead of remounting it. An uncached chat happened to reset the expanded state, because the panel renders nothing while loading and that unmounts the summary, but a cached chat resolves synchronously and kept the previous chat's expanded state, offering to collapse a summary that may not overflow at all. --- .../components/ChatSummaryPanel.stories.tsx | 84 ++++++++++++++++++- .../components/ChatSummaryPanel.tsx | 5 ++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx b/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx index e6fb51621ae6a..0cf15c6143bb3 100644 --- a/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import type { FC } from "react"; -import { expect, spyOn, waitFor, within } from "storybook/test"; +import { type FC, useState } from "react"; +import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; import { API } from "#/api/api"; import type * as TypesGen from "#/api/typesGenerated"; import { MockChat } from "#/testHelpers/chatEntities"; @@ -8,6 +8,17 @@ import { withDashboardProvider } from "#/testHelpers/storybook"; import { ChatSummaryPanel } from "./ChatSummaryPanel"; const ROOT_CHAT_ID = "root-chat-id"; +const OTHER_CHAT_ID = "other-chat-id"; + +const LONG_SUMMARY = [ + "Audited the whole chat pipeline and shipped a batch of fixes.", + "", + ...Array.from( + { length: 12 }, + (_, i) => + `- Reviewed subsystem number ${i + 1} and applied the corresponding fix so the behaviour matches the specification`, + ), +].join("\n"); const mockCost: TypesGen.ChatCost = { chat_id: MockChat.id, @@ -163,3 +174,72 @@ export const GatewayUnavailable: Story = { expect(API.experimental.getChatCost).not.toHaveBeenCalled(); }, }; + +// Navigating between chats swaps `chatId` on the existing panel instead of +// remounting it, so the disclosure state must not carry over. Without a reset +// the next chat renders fully expanded behind a "Show less" button, even when +// its own summary fits. +export const ExpansionResetsBetweenChats: Story = { + beforeEach: () => { + spyOn(API.experimental, "getChat").mockImplementation(async (chatId) => ({ + ...MockChat, + id: chatId, + summary: chatId === MockChat.id ? LONG_SUMMARY : "A summary that fits.", + })); + spyOn(API.experimental, "getChatCost").mockResolvedValue(mockCost); + }, + render: (args) => { + const [chatId, setChatId] = useState(MockChat.id); + return ( +
    + + +
    + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const switchChat = canvas.getByRole("button", { name: "Switch chat" }); + const expand = async () => { + const showMore = await canvas.findByRole("button", { + name: "Show more", + }); + await userEvent.click(showMore); + await expect( + canvas.getByRole("button", { name: "Show less" }), + ).toBeInTheDocument(); + }; + + // Warm both chats in the query cache first. While a chat is still + // uncached the panel renders nothing, which unmounts the summary and + // resets the disclosure state as a side effect. Once cached, the data is + // returned synchronously and the panel keeps the same summary instance + // across the switch, which is where the state can leak. + await expand(); + await userEvent.click(switchChat); + await waitFor(async () => { + await expect(canvas.getByText("A summary that fits.")).toBeVisible(); + }); + await userEvent.click(switchChat); + + await expand(); + await userEvent.click(switchChat); + await waitFor(async () => { + await expect(canvas.getByText("A summary that fits.")).toBeVisible(); + }); + + // The switched-to summary fits, so it must not offer to collapse. + await expect( + canvas.queryByRole("button", { name: "Show less" }), + ).not.toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx b/site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx index 7f4e0b0bedef4..8bc9c5a21a43a 100644 --- a/site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx @@ -32,6 +32,11 @@ export const ChatSummaryPanel: FC = ({ } else if (chatData) { content = ( Date: Thu, 20 Aug 2026 09:24:26 +0000 Subject: [PATCH 08/27] refactor(site/src/pages/AgentsPage/components): drop the chat summary disclosure toggle The summary panel is already a scroll container, so clamping the summary to a fixed height put a second, worse overflow mechanism in front of content the reader could already reach by scrolling. Generated summaries are capped at 600 runes server-side and rarely reach the clamp, so the toggle mostly never appeared. Removing it also removes the overflow measurement it required: the ResizeObserver, the settle debounce, the two refs, and the per-chat key that existed only to reset the expanded state. --- .../components/ChatSummary.stories.tsx | 64 ++----- .../AgentsPage/components/ChatSummary.tsx | 166 +++++------------- .../components/ChatSummaryPanel.stories.tsx | 78 ++------ .../components/ChatSummaryPanel.tsx | 5 - 4 files changed, 64 insertions(+), 249 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx b/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx index c31ebd19822df..cd27bc6421d02 100644 --- a/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx @@ -1,6 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { useState } from "react"; -import { expect, userEvent, waitFor, within } from "storybook/test"; +import { expect, within } from "storybook/test"; import { ChatSummary } from "./ChatSummary"; const MARKDOWN_SUMMARY = [ @@ -71,11 +70,6 @@ export const HeadlineAndBullets: Story = { // backticks. await expect(canvas.getByText("chatd.go")).toBeInTheDocument(); await expect(canvas.queryByText(/`/)).not.toBeInTheDocument(); - - // Short content fits the bound, so no toggle is offered. - await expect( - canvas.queryByRole("button", { name: "Show more" }), - ).not.toBeInTheDocument(); }, }; @@ -108,9 +102,8 @@ export const LegacyOrderedList: Story = { }, }; -// Links are rendered as plain text. `overflow-hidden` clips the collapsed -// content visually only, so a mounted anchor below the bound would still be -// reachable by keyboard and screen readers while invisible. +// Links render as plain text. A summary describes the chat rather than linking +// out of it, and any URL in one is model-authored. export const LinksRenderAsPlainText: Story = { args: { summary: @@ -124,8 +117,8 @@ export const LinksRenderAsPlainText: Story = { }; // The generation prompt preserves identifiers and wraps them in backticks, so -// a single token can be wider than the panel. It has to wrap, because the -// collapsed bound only reveals its toggle for vertical overflow. +// a single token can be wider than the panel. It has to wrap, or it widens the +// box past the column it sits in. export const LongIdentifierWraps: Story = { args: { summary: @@ -152,51 +145,16 @@ export const LongIdentifierWraps: Story = { }, }; -// A cache update can replace the summary in place, without remounting the -// panel, so the overflow toggle has to re-evaluate on new content. -export const SummaryReplacedInPlace: Story = { - render: (args) => { - const [summary, setSummary] = useState(MARKDOWN_SUMMARY); - return ( -
    - - -
    - ); - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await expect( - canvas.queryByRole("button", { name: "Show more" }), - ).not.toBeInTheDocument(); - - await userEvent.click( - canvas.getByRole("button", { name: "Simulate update" }), - ); - - await waitFor(async () => { - await expect( - canvas.getByRole("button", { name: "Show more" }), - ).toBeInTheDocument(); - }); - }, -}; - -export const LongSummaryExpands: Story = { +// The summary renders at its natural height. Nothing is clipped or hidden +// behind a disclosure toggle; the surrounding panel scrolls instead. +export const LongSummary: Story = { args: { summary: LONG_SUMMARY }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const showMore = await canvas.findByRole("button", { name: "Show more" }); - await userEvent.click(showMore); - - await waitFor(async () => { - await expect( - canvas.getByRole("button", { name: "Show less" }), - ).toBeInTheDocument(); - }); + const list = canvas.getByRole("list"); + await expect(within(list).getAllByRole("listitem")).toHaveLength(12); + await expect(within(list).getByText(/subsystem number 12/)).toBeVisible(); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatSummary.tsx b/site/src/pages/AgentsPage/components/ChatSummary.tsx index 121620853b380..ab05bf807dfb3 100644 --- a/site/src/pages/AgentsPage/components/ChatSummary.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummary.tsx @@ -1,10 +1,4 @@ -import { - type FC, - type ReactNode, - useLayoutEffect, - useRef, - useState, -} from "react"; +import type { FC, ReactNode } from "react"; import { InlineMarkdown } from "#/components/Markdown/InlineMarkdown"; import { Skeleton } from "#/components/Skeleton/Skeleton"; import { formatCostMicros } from "#/utils/currency"; @@ -15,13 +9,6 @@ const EMPTY_VALUE = "-"; /** Compact list spacing that keeps markers inside the narrow summary column. */ const LIST_CLASSES = "my-2 flex flex-col gap-1 pl-5"; -/** - * How long resizing must settle before overflow is remeasured. Dragging the - * right panel resizes the summary every frame, and the overflow verdict is a - * boolean that flips at most a couple of times per drag. - */ -const RESIZE_SETTLE_MS = 100; - interface ChatSummaryProps { summary: string | null; createdAt: string; @@ -108,120 +95,47 @@ interface ChatSummaryBodyProps { } /** - * Renders the stored summary markdown (a headline paragraph plus an optional - * bullet list) inside a bounded box, revealing a toggle only when the content - * actually overflows. `max-height` is used instead of `line-clamp` because - * `line-clamp` relies on `display: -webkit-box`, which clamps unreliably once - * the content contains nested block children such as `
    • `. + * Renders the stored summary markdown: a headline paragraph plus an optional + * bullet list. * - * Overflow is measured on the clamped box but observed on an inner unclamped - * element, so both panel resizes and in-place summary updates re-evaluate the - * toggle. + * The height is deliberately unbounded. Generated summaries are capped at 600 + * runes server-side, and the panel around this is already a scroll container, + * so clamping would only put a second, worse overflow mechanism in front of + * content the reader can already reach by scrolling. */ -const ChatSummaryBody: FC = ({ summary }) => { - const clampRef = useRef(null); - const contentRef = useRef(null); - const [isExpanded, setIsExpanded] = useState(false); - const [isOverflowing, setIsOverflowing] = useState(false); - - useLayoutEffect(() => { - // Overflow only needs measuring while collapsed. Skipping the expanded - // state preserves the verdict computed while collapsed, so the toggle - // stays visible; collapsing reruns this effect and remeasures. - if (isExpanded) { - return; - } - const clamp = clampRef.current; - const content = contentRef.current; - if (!clamp || !content) { - return; - } - const measure = () => - setIsOverflowing(clamp.scrollHeight > clamp.clientHeight); - measure(); - - if (typeof ResizeObserver === "undefined") { - return; - } - // Observe the inner element rather than the clamped one. The clamped box - // stops growing at its max height, so a summary that arrives via a cache - // update while the box is already pinned there would resize nothing and - // stay clipped with no toggle. The inner element is unclamped, so its - // height tracks the content and its width tracks the resizable panel. - // - // Debounce rather than schedule on an animation frame: observer - // callbacks are already delivered at most once per frame, so a frame - // callback would defer the same work instead of doing less of it. - // Settling skips the layout reads entirely while a drag is in flight. - let settleTimeout: ReturnType | undefined; - const observer = new ResizeObserver(() => { - clearTimeout(settleTimeout); - settleTimeout = setTimeout(measure, RESIZE_SETTLE_MS); - }); - observer.observe(content); - return () => { - clearTimeout(settleTimeout); - observer.disconnect(); - }; - }, [isExpanded]); - - return ( -
      -
      -
      - ( -

      {children}

      - ), - ul: ({ children }) => ( -
        {children}
      - ), - ol: ({ children }) => ( -
        {children}
      - ), - li: ({ children }) => ( -
    • {children}
    • - ), - // Render link text without an anchor. Clipping below the - // collapsed bound is visual only, so a mounted anchor would - // stay reachable by keyboard and screen readers while - // invisible. Summaries are generated text, not navigation. - a: ({ children }) => <>{children}, - }} - > - {summary} - -
    -
    - - {(isOverflowing || isExpanded) && ( - - )} -
    - ); -}; +const ChatSummaryBody: FC = ({ summary }) => ( +
    +

    {children}

    , + ul: ({ children }) => ( +
      {children}
    + ), + ol: ({ children }) => ( +
      {children}
    + ), + li: ({ children }) =>
  • {children}
  • , + // Render link text without an anchor. A summary describes the chat + // rather than linking out of it, and any URL here is model-authored, + // so it is not a navigation target worth mounting. + a: ({ children }) => <>{children}, + }} + > + {summary} +
    +
    +); interface ChatSummaryRowProps { label: string; diff --git a/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx b/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx index 0cf15c6143bb3..d6d382f5d4377 100644 --- a/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { type FC, useState } from "react"; -import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; +import type { FC } from "react"; +import { expect, spyOn, waitFor, within } from "storybook/test"; import { API } from "#/api/api"; import type * as TypesGen from "#/api/typesGenerated"; import { MockChat } from "#/testHelpers/chatEntities"; @@ -8,7 +8,6 @@ import { withDashboardProvider } from "#/testHelpers/storybook"; import { ChatSummaryPanel } from "./ChatSummaryPanel"; const ROOT_CHAT_ID = "root-chat-id"; -const OTHER_CHAT_ID = "other-chat-id"; const LONG_SUMMARY = [ "Audited the whole chat pipeline and shipped a batch of fixes.", @@ -175,71 +174,20 @@ export const GatewayUnavailable: Story = { }, }; -// Navigating between chats swaps `chatId` on the existing panel instead of -// remounting it, so the disclosure state must not carry over. Without a reset -// the next chat renders fully expanded behind a "Show less" button, even when -// its own summary fits. -export const ExpansionResetsBetweenChats: Story = { - beforeEach: () => { - spyOn(API.experimental, "getChat").mockImplementation(async (chatId) => ({ - ...MockChat, - id: chatId, - summary: chatId === MockChat.id ? LONG_SUMMARY : "A summary that fits.", - })); - spyOn(API.experimental, "getChatCost").mockResolvedValue(mockCost); - }, - render: (args) => { - const [chatId, setChatId] = useState(MockChat.id); - return ( -
    - - -
    - ); - }, +// A summary taller than the panel stays fully rendered and reachable by +// scrolling the panel, rather than being clipped behind a disclosure toggle. +export const LongSummary: Story = { + beforeEach: () => mockRequests({ summary: LONG_SUMMARY }), play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const switchChat = canvas.getByRole("button", { name: "Switch chat" }); - const expand = async () => { - const showMore = await canvas.findByRole("button", { - name: "Show more", - }); - await userEvent.click(showMore); - await expect( - canvas.getByRole("button", { name: "Show less" }), - ).toBeInTheDocument(); - }; - - // Warm both chats in the query cache first. While a chat is still - // uncached the panel renders nothing, which unmounts the summary and - // resets the disclosure state as a side effect. Once cached, the data is - // returned synchronously and the panel keeps the same summary instance - // across the switch, which is where the state can leak. - await expand(); - await userEvent.click(switchChat); - await waitFor(async () => { - await expect(canvas.getByText("A summary that fits.")).toBeVisible(); - }); - await userEvent.click(switchChat); - - await expand(); - await userEvent.click(switchChat); - await waitFor(async () => { - await expect(canvas.getByText("A summary that fits.")).toBeVisible(); + await waitFor(() => { + expect(canvas.getByRole("list")).toBeInTheDocument(); }); - // The switched-to summary fits, so it must not offer to collapse. - await expect( - canvas.queryByRole("button", { name: "Show less" }), - ).not.toBeInTheDocument(); + expect( + within(canvas.getByRole("list")).getAllByRole("listitem"), + ).toHaveLength(12); + // The metadata below the summary is pushed out of view but still rendered. + expect(canvas.getByText("Created:")).toBeInTheDocument(); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx b/site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx index 8bc9c5a21a43a..7f4e0b0bedef4 100644 --- a/site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx @@ -32,11 +32,6 @@ export const ChatSummaryPanel: FC = ({ } else if (chatData) { content = ( Date: Thu, 20 Aug 2026 10:35:12 +0000 Subject: [PATCH 09/27] feat(coderd/x/chatd): allow headline-only chat summaries Requiring at least two bullets gave a trivial chat two bad options: the model pads the summary with filler, or validation rejects it and the panel falls back to "No summary yet". The rejection was silent, logged only at Debug. Drop the lower bound and keep the cap at four, and tell the model to leave the bullets field empty when the headline already covers the whole chat. The serializer already renders a bare headline for empty bullets, which is the same path subagent summaries take. --- coderd/x/chatd/quickgen.go | 24 +++++++++++-------- coderd/x/chatd/summarygen_internal_test.go | 9 +++---- .../components/ChatSummary.stories.tsx | 8 ++++--- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/coderd/x/chatd/quickgen.go b/coderd/x/chatd/quickgen.go index debd2f5f917d8..00dbb83867d23 100644 --- a/coderd/x/chatd/quickgen.go +++ b/coderd/x/chatd/quickgen.go @@ -971,6 +971,7 @@ func generateManualTitle( const chatSummaryGenerationPrompt = "You summarize an AI coding chat for a quick-reference popover. " + "Populate the headline field with one sentence naming what the conversation is about and its outcome. " + "Populate the bullets field with 2 to 4 short bullets covering what was done or attempted, each a single line. " + + "Leave the bullets field empty when the headline already covers the whole chat, rather than padding it with filler. " + "Write about the conversation in the third person. " + "Preserve specific identifiers such as PR numbers, repo names, file paths, function names, and error messages, " + "wrapping them in backticks. " + @@ -991,13 +992,15 @@ const ( summaryHeadlineMaxRunes = 200 summaryHeadlineMaxSentences = 2 summaryBulletMaxRunes = 160 - summaryMinBullets = 2 - summaryMaxBullets = 4 + // Only an upper bound. A trivial chat is fully described by its headline, + // and requiring bullets there would either pad the summary with filler or + // reject it outright, leaving the panel empty. + summaryMaxBullets = 4 ) type generatedChatSummary struct { Headline string `json:"headline" description:"One sentence naming what the chat is about and its outcome"` - Bullets []string `json:"bullets" description:"2-4 short bullets, each one line, covering what was done or attempted"` + Bullets []string `json:"bullets" description:"2-4 short bullets, each one line, covering what was done or attempted; empty when the headline already covers the whole chat"` } // renderChatSummaryTranscript renders chat history as plain text for summary @@ -1097,9 +1100,10 @@ func boundTranscriptHeadTail(lines []string, maxRunes int) string { } // generateChatSummary generates a whole-chat summary from a transcript as a -// one-sentence headline plus 2-4 bullets, serialized to markdown by -// formatChatSummaryMarkdown. A blank or invalid result returns an error so -// callers preserve any existing summary rather than clearing it. +// one-sentence headline plus up to 4 bullets, serialized to markdown by +// formatChatSummaryMarkdown. Bullets are omitted for chats the headline +// already covers. A blank or invalid result returns an error so callers +// preserve any existing summary rather than clearing it. func generateChatSummary( ctx context.Context, model fantasy.LanguageModel, @@ -1132,7 +1136,7 @@ func generateChatSummary( result, genErr = object.Generate[generatedChatSummary](retryCtx, model, fantasy.ObjectCall{ Prompt: prompt, SchemaName: "chat_summary", - SchemaDescription: "Summarize the whole chat as a one-sentence headline plus 2-4 short bullets.", + SchemaDescription: "Summarize the whole chat as a one-sentence headline plus up to 4 short bullets.", MaxOutputTokens: &maxOutputTokens, }) return genErr @@ -1217,10 +1221,10 @@ func validateGeneratedChatSummary(summary generatedChatSummary) error { if countSentenceTerminators(summary.Headline) > summaryHeadlineMaxSentences { return xerrors.Errorf("generated chat summary headline exceeded %d sentences", summaryHeadlineMaxSentences) } - if len(summary.Bullets) < summaryMinBullets || len(summary.Bullets) > summaryMaxBullets { + if len(summary.Bullets) > summaryMaxBullets { return xerrors.Errorf( - "generated chat summary had %d bullets, want %d to %d", - len(summary.Bullets), summaryMinBullets, summaryMaxBullets, + "generated chat summary had %d bullets, want at most %d", + len(summary.Bullets), summaryMaxBullets, ) } for _, bullet := range summary.Bullets { diff --git a/coderd/x/chatd/summarygen_internal_test.go b/coderd/x/chatd/summarygen_internal_test.go index 0a1908228b52a..762b62a6807d3 100644 --- a/coderd/x/chatd/summarygen_internal_test.go +++ b/coderd/x/chatd/summarygen_internal_test.go @@ -258,14 +258,15 @@ func TestValidateGeneratedChatSummary(t *testing.T) { wantErr: true, }, { - name: "TooFewBullets", + // A chat can be small enough that one bullet is all there is to say. + name: "SingleBullet", summary: generatedChatSummary{Headline: "Fixed it.", Bullets: []string{"Only one"}}, - wantErr: true, }, { + // A trivial chat is fully described by its headline. Rejecting this + // would leave the panel empty rather than showing the short summary. name: "NoBullets", - summary: generatedChatSummary{Headline: "Fixed it."}, - wantErr: true, + summary: generatedChatSummary{Headline: "Fixed a typo in `README.md`."}, }, { name: "TooManyBullets", diff --git a/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx b/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx index cd27bc6421d02..10d6b88b7fed3 100644 --- a/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx @@ -73,9 +73,11 @@ export const HeadlineAndBullets: Story = { }, }; -// Summaries generated before the structured format are plain prose. They must -// still render, since there is no backfill. -export const LegacyProseSummary: Story = { +// A summary can be a bare headline with no bullets: summaries generated before +// the structured format are plain prose and there is no backfill, subagent +// summaries are extracted from the agent's report rather than generated, and a +// trivial chat is allowed to omit bullets instead of padding them. +export const HeadlineOnlySummary: Story = { args: { summary: "Investigated the flaky CI job, traced it to a race in the cache layer, and added a regression test.", From 4a6ff2786b7b82933346f6b7e322bf23d5b39327 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Sat, 22 Aug 2026 07:55:19 +0000 Subject: [PATCH 10/27] refactor(coderd/x/chatd): drop summary checks that normalization makes dead Normalization runs before validation and serialization, so several downstream defensive checks could never fire: - normalizeSummaryField guarded the empty case that strings.Fields already handles. - formatChatSummaryMarkdown re-filtered blank bullets that normalizeSummaryBullets had already dropped, and built the output with a Builder plus a trailing-newline trim where a Join does. - validateGeneratedChatSummary rejected bullets containing newlines, which strings.Fields collapses before validation sees them. Record the normalization precondition on formatChatSummaryMarkdown so the invariant is documented rather than re-checked, and drop the tests that only covered the dead branches. Blank-bullet and newline handling stay covered by TestNormalizeSummaryBullets and TestNormalizeSummaryField at the layer that actually performs them. Also drop the duplicated LongSummary panel story; it repeated the ChatSummary story verbatim while asserting nothing about the panel. --- coderd/x/chatd/quickgen.go | 25 ++++------------- coderd/x/chatd/summarygen_internal_test.go | 20 ------------- .../components/ChatSummaryPanel.stories.tsx | 28 ------------------- 3 files changed, 5 insertions(+), 68 deletions(-) diff --git a/coderd/x/chatd/quickgen.go b/coderd/x/chatd/quickgen.go index 2ef9132847479..669ac47d77c28 100644 --- a/coderd/x/chatd/quickgen.go +++ b/coderd/x/chatd/quickgen.go @@ -1065,12 +1065,7 @@ func generateChatSummary( // preserves backticks, so a field ending in an inline code span keeps a // balanced pair. func normalizeSummaryField(text string) string { - text = strings.TrimSpace(text) - if text == "" { - return "" - } - - text = strings.Trim(text, "\"'") + text = strings.Trim(strings.TrimSpace(text), "\"'") return strings.Join(strings.Fields(text), " ") } @@ -1090,22 +1085,15 @@ func normalizeSummaryBullets(bullets []string) []string { // formatChatSummaryMarkdown renders the stored summary as a headline paragraph // followed by an optional bullet list, separated by a blank line. Both summary // producers go through here so the stored format stays consistent. +// +// Bullets must already be normalized by normalizeSummaryBullets, which drops +// blank entries and collapses newlines. func formatChatSummaryMarkdown(headline string, bullets []string) string { headline = strings.TrimSpace(headline) if len(bullets) == 0 { return headline } - - var out strings.Builder - _, _ = out.WriteString(headline) - _, _ = out.WriteString("\n") - for _, bullet := range bullets { - if bullet = strings.TrimSpace(bullet); bullet != "" { - _, _ = out.WriteString("\n- ") - _, _ = out.WriteString(bullet) - } - } - return strings.TrimSpace(out.String()) + return strings.TrimSpace(headline + "\n\n- " + strings.Join(bullets, "\n- ")) } // validateGeneratedChatSummary checks the structured fields before they are @@ -1132,9 +1120,6 @@ func validateGeneratedChatSummary(summary generatedChatSummary) error { if len([]rune(bullet)) > summaryBulletMaxRunes { return xerrors.Errorf("generated chat summary bullet exceeded %d runes", summaryBulletMaxRunes) } - if strings.ContainsAny(bullet, "\n\r") { - return xerrors.New("generated chat summary bullet contained a newline") - } } if rendered := formatChatSummaryMarkdown(summary.Headline, summary.Bullets); len([]rune(rendered)) > summaryMaxRunes { return xerrors.Errorf("generated chat summary exceeded %d runes", summaryMaxRunes) diff --git a/coderd/x/chatd/summarygen_internal_test.go b/coderd/x/chatd/summarygen_internal_test.go index 762b62a6807d3..43d31f3437438 100644 --- a/coderd/x/chatd/summarygen_internal_test.go +++ b/coderd/x/chatd/summarygen_internal_test.go @@ -284,14 +284,6 @@ func TestValidateGeneratedChatSummary(t *testing.T) { }, wantErr: true, }, - { - name: "BulletWithNewline", - summary: generatedChatSummary{ - Headline: "Fixed it.", - Bullets: []string{"Fine", "Broken\nacross lines"}, - }, - wantErr: true, - }, { name: "SerializedTooLong", summary: generatedChatSummary{ @@ -397,18 +389,6 @@ func TestFormatChatSummaryMarkdown(t *testing.T) { bullets: []string{"Traced the race", "Added a test"}, want: "Fixed the flaky CI job.\n\n- Traced the race\n- Added a test", }, - { - name: "DropsEmptyBullets", - headline: "Fixed it.", - bullets: []string{"Kept", " ", "Also kept"}, - want: "Fixed it.\n\n- Kept\n- Also kept", - }, - { - name: "AllBulletsEmptyKeepsHeadline", - headline: "Fixed it.", - bullets: []string{" ", ""}, - want: "Fixed it.", - }, } for _, tt := range tests { diff --git a/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx b/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx index d6d382f5d4377..e6fb51621ae6a 100644 --- a/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx @@ -9,16 +9,6 @@ import { ChatSummaryPanel } from "./ChatSummaryPanel"; const ROOT_CHAT_ID = "root-chat-id"; -const LONG_SUMMARY = [ - "Audited the whole chat pipeline and shipped a batch of fixes.", - "", - ...Array.from( - { length: 12 }, - (_, i) => - `- Reviewed subsystem number ${i + 1} and applied the corresponding fix so the behaviour matches the specification`, - ), -].join("\n"); - const mockCost: TypesGen.ChatCost = { chat_id: MockChat.id, total_cost_micros: 1_250_000, @@ -173,21 +163,3 @@ export const GatewayUnavailable: Story = { expect(API.experimental.getChatCost).not.toHaveBeenCalled(); }, }; - -// A summary taller than the panel stays fully rendered and reachable by -// scrolling the panel, rather than being clipped behind a disclosure toggle. -export const LongSummary: Story = { - beforeEach: () => mockRequests({ summary: LONG_SUMMARY }), - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await waitFor(() => { - expect(canvas.getByRole("list")).toBeInTheDocument(); - }); - - expect( - within(canvas.getByRole("list")).getAllByRole("listitem"), - ).toHaveLength(12); - // The metadata below the summary is pushed out of view but still rendered. - expect(canvas.getByText("Created:")).toBeInTheDocument(); - }, -}; From cf5e5af5deb7b9d3f850208767fa977402e3c359 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 24 Aug 2026 10:41:18 +0000 Subject: [PATCH 11/27] refactor: trim summary comments and inline list classes Shorten comments added in this PR to the rationale they carry, drop the ones that restated the code, and inline the shared Tailwind list classes at each call site instead of routing them through a constant. --- coderd/x/chatd/chatd.go | 4 +- coderd/x/chatd/quickgen.go | 42 +++++++------------ coderd/x/chatd/summarygen_internal_test.go | 11 ++--- .../components/ChatSummary.stories.tsx | 31 +++++--------- .../AgentsPage/components/ChatSummary.tsx | 40 ++++++++---------- 5 files changed, 47 insertions(+), 81 deletions(-) diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 3fd6fe5aa84e9..918d6b5f1fb4c 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -4894,9 +4894,7 @@ func (p *Server) storeSubagentReportSummary( slog.F("chat_id", chat.ID), slog.Error(err)) return } - // Subagent summaries are extracted from an existing report rather than - // generated, so they have no bullets; the serializer keeps the headline - // as-is and only adds structure when bullets exist. + // Extracted from the report rather than generated, so no bullets. summary := formatChatSummaryMarkdown(subagentReportSummarySnippet(report), nil) if summary == "" { return diff --git a/coderd/x/chatd/quickgen.go b/coderd/x/chatd/quickgen.go index 669ac47d77c28..bacc2da35f914 100644 --- a/coderd/x/chatd/quickgen.go +++ b/coderd/x/chatd/quickgen.go @@ -897,16 +897,13 @@ const ( // Cap a single turn so one long message cannot dominate the budget. summaryTranscriptPerMessageMaxRunes = 4000 summaryMaxOutputTokens = 512 - // Reject pathologically long or verbose summaries. The caps apply to the - // structured fields before serialization, plus a ceiling on the rendered - // markdown so the panel stays scannable. + // Reject pathologically long or verbose summaries. summaryMaxRunes = 600 summaryHeadlineMaxRunes = 200 summaryHeadlineMaxSentences = 2 summaryBulletMaxRunes = 160 - // Only an upper bound. A trivial chat is fully described by its headline, - // and requiring bullets there would either pad the summary with filler or - // reject it outright, leaving the panel empty. + // Upper bound only; requiring bullets would pad trivial chats with + // filler or reject them, leaving the panel empty. summaryMaxBullets = 4 ) @@ -1019,11 +1016,9 @@ func summaryObjectCall(resolved resolvedModelCall) fantasy.ObjectCall { ) } -// generateChatSummary generates a whole-chat summary from a transcript as a -// one-sentence headline plus up to 4 bullets, serialized to markdown by -// formatChatSummaryMarkdown. Bullets are omitted for chats the headline -// already covers. A blank or invalid result returns an error so callers -// preserve any existing summary rather than clearing it. +// generateChatSummary generates a headline-plus-bullets summary from a +// transcript, serialized to markdown. A blank or invalid result returns an +// error so callers preserve any existing summary rather than clearing it. func generateChatSummary( ctx context.Context, model fantasy.LanguageModel, @@ -1060,18 +1055,14 @@ func generateChatSummary( return formatChatSummaryMarkdown(summary.Headline, summary.Bullets), result.Usage, nil } -// normalizeSummaryField collapses internal whitespace so a field stays on one -// line, and strips surrounding quotes. Unlike normalizeShortTextOutput it -// preserves backticks, so a field ending in an inline code span keeps a -// balanced pair. +// normalizeSummaryField collapses a field onto one line. Unlike +// normalizeShortTextOutput it preserves backticks, keeping inline code spans +// balanced. func normalizeSummaryField(text string) string { text = strings.Trim(strings.TrimSpace(text), "\"'") return strings.Join(strings.Fields(text), " ") } -// normalizeSummaryBullets normalizes each bullet and drops the ones that -// normalize to nothing, so blank model output does not render as an empty -// list item. func normalizeSummaryBullets(bullets []string) []string { normalized := make([]string, 0, len(bullets)) for _, bullet := range bullets { @@ -1082,12 +1073,8 @@ func normalizeSummaryBullets(bullets []string) []string { return normalized } -// formatChatSummaryMarkdown renders the stored summary as a headline paragraph -// followed by an optional bullet list, separated by a blank line. Both summary -// producers go through here so the stored format stays consistent. -// -// Bullets must already be normalized by normalizeSummaryBullets, which drops -// blank entries and collapses newlines. +// formatChatSummaryMarkdown renders a headline paragraph plus an optional +// bullet list. Bullets must already be normalized: no blanks, no newlines. func formatChatSummaryMarkdown(headline string, bullets []string) string { headline = strings.TrimSpace(headline) if len(bullets) == 0 { @@ -1096,10 +1083,9 @@ func formatChatSummaryMarkdown(headline string, bullets []string) string { return strings.TrimSpace(headline + "\n\n- " + strings.Join(bullets, "\n- ")) } -// validateGeneratedChatSummary checks the structured fields before they are -// serialized. Validating here rather than over the rendered markdown keeps the -// sentence cap meaningful: bullets routinely omit trailing punctuation, so a -// sentence count over the serialized string would pass almost anything. +// validateGeneratedChatSummary checks the structured fields rather than the +// rendered markdown: bullets omit trailing punctuation, so a sentence count +// over the serialized string would pass almost anything. func validateGeneratedChatSummary(summary generatedChatSummary) error { if summary.Headline == "" { return xerrors.New("generated chat summary headline was empty") diff --git a/coderd/x/chatd/summarygen_internal_test.go b/coderd/x/chatd/summarygen_internal_test.go index 43d31f3437438..3ec0545f8447d 100644 --- a/coderd/x/chatd/summarygen_internal_test.go +++ b/coderd/x/chatd/summarygen_internal_test.go @@ -258,13 +258,11 @@ func TestValidateGeneratedChatSummary(t *testing.T) { wantErr: true, }, { - // A chat can be small enough that one bullet is all there is to say. name: "SingleBullet", summary: generatedChatSummary{Headline: "Fixed it.", Bullets: []string{"Only one"}}, }, { - // A trivial chat is fully described by its headline. Rejecting this - // would leave the panel empty rather than showing the short summary. + // A trivial chat is fully described by its headline. name: "NoBullets", summary: generatedChatSummary{Headline: "Fixed a typo in `README.md`."}, }, @@ -340,8 +338,7 @@ func TestNormalizeSummaryField(t *testing.T) { {name: "CollapsesRuns", text: "Fixed the\t\trace", want: "Fixed the race"}, {name: "StripsSurroundingQuotes", text: `"Fixed the race"`, want: "Fixed the race"}, { - // normalizeShortTextOutput would strip this trailing backtick and - // leave an unbalanced inline code span. + // normalizeShortTextOutput would strip this and unbalance the span. name: "PreservesTrailingBacktick", text: "Fixed `cache.go`", want: "Fixed `cache.go`", @@ -382,8 +379,8 @@ func TestFormatChatSummaryMarkdown(t *testing.T) { want: "Fixed the flaky CI job.", }, { - // A blank line must separate the paragraph from the list, or - // CommonMark folds the first bullet into the headline paragraph. + // Without the blank line, CommonMark folds the first bullet + // into the headline paragraph. name: "HeadlineAndBullets", headline: "Fixed the flaky CI job.", bullets: []string{"Traced the race", "Added a test"}, diff --git a/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx b/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx index 10d6b88b7fed3..1a09ff9878fc8 100644 --- a/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx @@ -66,17 +66,14 @@ export const HeadlineAndBullets: Story = { const list = canvas.getByRole("list"); await expect(within(list).getAllByRole("listitem")).toHaveLength(3); - // Identifiers wrapped in backticks render as inline code, not literal - // backticks. + // Backticked identifiers render as inline code, not literal backticks. await expect(canvas.getByText("chatd.go")).toBeInTheDocument(); await expect(canvas.queryByText(/`/)).not.toBeInTheDocument(); }, }; -// A summary can be a bare headline with no bullets: summaries generated before -// the structured format are plain prose and there is no backfill, subagent -// summaries are extracted from the agent's report rather than generated, and a -// trivial chat is allowed to omit bullets instead of padding them. +// Headline-only summaries: legacy prose, subagent report snippets, and +// trivial chats whose headline covers everything. export const HeadlineOnlySummary: Story = { args: { summary: @@ -91,9 +88,8 @@ export const HeadlineOnlySummary: Story = { }, }; -// A legacy prose summary that happens to start with "1. " parses as an ordered -// list. `ol` is allowlisted so the items keep a list parent instead of -// rendering as orphan `li` elements. +// A prose summary starting with "1. " parses as an ordered list; `ol` is +// allowlisted so the items keep a list parent. export const LegacyOrderedList: Story = { args: { summary: "1. Fixed the race\n2. Added a test" }, play: async ({ canvasElement }) => { @@ -104,8 +100,6 @@ export const LegacyOrderedList: Story = { }, }; -// Links render as plain text. A summary describes the chat rather than linking -// out of it, and any URL in one is model-authored. export const LinksRenderAsPlainText: Story = { args: { summary: @@ -118,18 +112,15 @@ export const LinksRenderAsPlainText: Story = { }, }; -// The generation prompt preserves identifiers and wraps them in backticks, so -// a single token can be wider than the panel. It has to wrap, or it widens the -// box past the column it sits in. +// A single backticked identifier can be wider than the panel and must wrap. export const LongIdentifierWraps: Story = { args: { summary: "Fixed `TestValidateGeneratedChatSummaryHeadlineExceedsBothTheRuneAndSentenceCaps` in `coderd/x/chatd/summarygen_internal_test.go`.", }, - // Pinned narrower than the panel's 360px minimum so the identifier cannot - // fit on one line. The wrapped layout itself is covered by visual - // regression snapshots; per FE10 the assertion here stays semantic rather - // than measuring geometry. + // Narrower than the panel minimum so the identifier cannot fit on one + // line. Layout is covered by visual snapshots; per FE10 the assertion + // stays semantic. decorators: [ (Story) => (
    @@ -147,8 +138,8 @@ export const LongIdentifierWraps: Story = { }, }; -// The summary renders at its natural height. Nothing is clipped or hidden -// behind a disclosure toggle; the surrounding panel scrolls instead. +// Renders at natural height; the surrounding panel scrolls instead of +// clipping or collapsing. export const LongSummary: Story = { args: { summary: LONG_SUMMARY }, play: async ({ canvasElement }) => { diff --git a/site/src/pages/AgentsPage/components/ChatSummary.tsx b/site/src/pages/AgentsPage/components/ChatSummary.tsx index ab05bf807dfb3..3576fe544cc11 100644 --- a/site/src/pages/AgentsPage/components/ChatSummary.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummary.tsx @@ -6,9 +6,6 @@ import { DATE_FORMAT, formatDateTime } from "#/utils/time"; const EMPTY_VALUE = "-"; -/** Compact list spacing that keeps markers inside the narrow summary column. */ -const LIST_CLASSES = "my-2 flex flex-col gap-1 pl-5"; - interface ChatSummaryProps { summary: string | null; createdAt: string; @@ -95,40 +92,37 @@ interface ChatSummaryBodyProps { } /** - * Renders the stored summary markdown: a headline paragraph plus an optional - * bullet list. - * - * The height is deliberately unbounded. Generated summaries are capped at 600 - * runes server-side, and the panel around this is already a scroll container, - * so clamping would only put a second, worse overflow mechanism in front of - * content the reader can already reach by scrolling. + * Height is deliberately unbounded: summaries are capped server-side and the + * surrounding panel already scrolls, so clamping would only add a second, + * worse overflow mechanism. */ const ChatSummaryBody: FC = ({ summary }) => (

    {children}

    , ul: ({ children }) => ( -
      {children}
    +
      + {children} +
    ), ol: ({ children }) => ( -
      {children}
    +
      + {children} +
    ), li: ({ children }) =>
  • {children}
  • , - // Render link text without an anchor. A summary describes the chat - // rather than linking out of it, and any URL here is model-authored, - // so it is not a navigation target worth mounting. + // A summary describes the chat rather than linking out of it, so + // model-authored URLs render as plain text. a: ({ children }) => <>{children}, }} > From 6df9da26c0efeee8191870a28c5a4dfe9e822d03 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 24 Aug 2026 18:23:22 +0000 Subject: [PATCH 12/27] chore: improvements --- coderd/apidoc/docs.go | 2 + coderd/x/chatd/chatd.go | 2 + codersdk/chats.go | 3 +- docs/reference/api/schemas.md | 2 +- site/src/api/queries/chats.test.ts | 2 + site/src/api/typesGenerated.ts | 2 + site/src/pages/AgentsPage/AgentChatPage.tsx | 2 + .../AgentsPage/AgentChatPageView.stories.tsx | 1 + .../pages/AgentsPage/AgentChatPageView.tsx | 3 + .../src/pages/AgentsPage/AgentsPageLayout.tsx | 63 ++++++++++++++++++- .../components/ChatSummary.stories.tsx | 45 ++++++++++++- .../AgentsPage/components/ChatSummary.tsx | 52 +++++++++++++-- .../components/ChatSummaryPanel.stories.tsx | 62 +++++++++++++++++- .../components/ChatSummaryPanel.tsx | 12 +++- 14 files changed, 241 insertions(+), 12 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index a8f25d7cce4b0..a59c3aaf34935 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -18996,6 +18996,7 @@ const docTemplate = `{ "status_change", "summary_change", "chat_summary_change", + "chat_summary_generating", "title_change", "created", "deleted", @@ -19007,6 +19008,7 @@ const docTemplate = `{ "ChatWatchEventKindStatusChange", "ChatWatchEventKindSummaryChange", "ChatWatchEventKindChatSummaryChange", + "ChatWatchEventKindChatSummaryGenerating", "ChatWatchEventKindTitleChange", "ChatWatchEventKindCreated", "ChatWatchEventKindDeleted", diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 918d6b5f1fb4c..4235d3da4f4dc 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -4755,6 +4755,8 @@ func (p *Server) generateAndStoreChatSummary( return } + p.publishChatPubsubEvent(chat, codersdk.ChatWatchEventKindChatSummaryGenerating, nil) + summaryCtx, cancelGen := context.WithTimeout(ctx, chatSummaryGenerateTimeout) defer cancelGen() summary, _, genErr := generateChatSummary(summaryCtx, resolved.model.LanguageModel(), summaryObjectCall(resolved), transcript) diff --git a/codersdk/chats.go b/codersdk/chats.go index cc0c495cedd07..4a98d613b13af 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -1863,7 +1863,8 @@ const ( // summary. It is distinct from SummaryChange (bound to last_turn_summary) so // the frontend updates one field without disturbing the other. ChatWatchEventKindChatSummaryChange ChatWatchEventKind = "chat_summary_change" - ChatWatchEventKindTitleChange ChatWatchEventKind = "title_change" + ChatWatchEventKindChatSummaryGenerating ChatWatchEventKind = "chat_summary_generating" + ChatWatchEventKindTitleChange ChatWatchEventKind = "title_change" ChatWatchEventKindCreated ChatWatchEventKind = "created" ChatWatchEventKindDeleted ChatWatchEventKind = "deleted" ChatWatchEventKindDiffStatusChange ChatWatchEventKind = "diff_status_change" diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 97c7aa66928a4..b4d19f999a8a0 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -4294,7 +4294,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | Value(s) | |----------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action_required`, `chat_summary_change`, `context_dirty`, `created`, `deleted`, `diff_status_change`, `status_change`, `summary_change`, `title_change` | +| `action_required`, `chat_summary_change`, `chat_summary_generating`, `context_dirty`, `created`, `deleted`, `diff_status_change`, `status_change`, `summary_change`, `title_change` | ## codersdk.ClusterConfig diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index 1715d440d239c..211f4b3a3262e 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -3364,6 +3364,7 @@ describe("semantic cache operations: prefix invalidations", () => { const expectedByKind: Record = { action_required: true, chat_summary_change: false, + chat_summary_generating: false, context_dirty: false, created: false, deleted: false, @@ -3443,6 +3444,7 @@ describe("semantic cache operations: prefix invalidations", () => { const expectedByKind: Record = { action_required: true, chat_summary_change: false, + chat_summary_generating: false, context_dirty: false, created: false, deleted: false, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 2e693d2cfc31f..9c898f210f8a5 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3612,6 +3612,7 @@ export interface ChatWatchEvent { export type ChatWatchEventKind = | "action_required" | "chat_summary_change" + | "chat_summary_generating" | "context_dirty" | "created" | "deleted" @@ -3623,6 +3624,7 @@ export type ChatWatchEventKind = export const ChatWatchEventKinds: ChatWatchEventKind[] = [ "action_required", "chat_summary_change", + "chat_summary_generating", "context_dirty", "created", "deleted", diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index bde9cd581b7eb..56d05088dd690 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -863,6 +863,7 @@ const AgentChatPage: FC = () => { isSidebarCollapsed, onToggleSidebarCollapsed, onChatReady, + summaryGeneratingChatIds, } = useOutletContext(); const queryClient = useQueryClient(); const { permissions, user: currentUser } = useAuthenticated(); @@ -2000,6 +2001,7 @@ const AgentChatPage: FC = () => { workspace={workspace} workspaceAgent={workspaceAgent} chatBuildId={chatQuery.data?.build_id} + isChatSummaryGenerating={summaryGeneratingChatIds?.has(agentId) ?? false} store={store} initialChatStatus={chatQuery.data.status} initialMessages={chatMessagesList ?? []} diff --git a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx index d56fb1a7a7942..6c6dea0fa3e13 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx @@ -138,6 +138,7 @@ const StoryAgentChatPageView: FC = ({ editing, ...overrides }) => { parentChat: undefined as TypesGen.Chat | undefined, isArchived: false, isSharedChat: false, + isChatSummaryGenerating: false, chatOwner: undefined as ComponentProps< typeof AgentChatPageView >["chatOwner"], diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 7492740b6befe..2749b734d0486 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -123,6 +123,7 @@ interface AgentChatPageViewProps { workspaceAgent?: TypesGen.WorkspaceAgent; workspace?: TypesGen.Workspace; chatBuildId?: string; + isChatSummaryGenerating: boolean; // Store handle. store: ChatStoreHandle; @@ -327,6 +328,7 @@ export const AgentChatPageView: FC = ({ workspaceAgent, workspace, chatBuildId, + isChatSummaryGenerating, store, initialChatStatus, initialMessages, @@ -705,6 +707,7 @@ export const AgentChatPageView: FC = ({ ); case "git": diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.tsx index 552a6fbd4910e..ea3abf09851c9 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.tsx @@ -124,6 +124,8 @@ export interface AgentsPageOutletContext { onToggleSidebarCollapsed: () => void; onExpandSidebar: () => void; onChatReady: () => void; + /** Root chats for which the server has reported active summary generation. */ + summaryGeneratingChatIds?: ReadonlySet; } const FILTER_MEMBERSHIP_EVENT_KINDS = new Set([ @@ -145,6 +147,10 @@ const POST_TURN_BILLED_EVENT_KINDS = new Set([ "title_change", ]); +// Matches chatd.chatSummaryWorkTimeout so a failed generation cannot leave +// the transient loading state visible indefinitely. +const chatSummaryGeneratingTimeoutMs = 120_000; + export const chatCostIdToInvalidate = ( chat: TypesGen.Chat, eventKind: TypesGen.ChatWatchEventKind, @@ -175,6 +181,9 @@ const AgentsPageLayout: FC = () => { setSearchParams, ); const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false); + const [summaryGeneratingChatIds, setSummaryGeneratingChatIds] = useState< + ReadonlySet + >(() => new Set()); // The global CSS sets scrollbar-gutter: stable on to prevent // layout shift on pages that toggle scrollbars. The agents page @@ -562,7 +571,39 @@ const AgentsPageLayout: FC = () => { void invalidateChatSearches(queryClient); }, [agentId, queryClient]); useEffect(() => { - return createReconnectingWebSocket({ + const summaryGeneratingTimeouts = new Map< + string, + ReturnType + >(); + const clearSummaryGenerating = (chatId: string) => { + setSummaryGeneratingChatIds((current) => { + if (!current.has(chatId)) { + return current; + } + const next = new Set(current); + next.delete(chatId); + return next; + }); + }; + const markSummaryGenerating = (chatId: string) => { + const previousTimeout = summaryGeneratingTimeouts.get(chatId); + if (previousTimeout !== undefined) { + clearTimeout(previousTimeout); + } + setSummaryGeneratingChatIds((current) => { + if (current.has(chatId)) { + return current; + } + return new Set(current).add(chatId); + }); + const timeout = setTimeout(() => { + summaryGeneratingTimeouts.delete(chatId); + clearSummaryGenerating(chatId); + }, chatSummaryGeneratingTimeoutMs); + summaryGeneratingTimeouts.set(chatId, timeout); + }; + + const dispose = createReconnectingWebSocket({ connect() { const ws = watchChats(); @@ -573,6 +614,19 @@ const AgentsPageLayout: FC = () => { } const chatEvent = event.parsedMessage; const updatedChat = chatEvent.chat; + if (chatEvent.kind === "chat_summary_generating") { + markSummaryGenerating(updatedChat.id); + } else if ( + chatEvent.kind === "chat_summary_change" || + chatEvent.kind === "deleted" + ) { + const timeout = summaryGeneratingTimeouts.get(updatedChat.id); + if (timeout !== undefined) { + clearTimeout(timeout); + summaryGeneratingTimeouts.delete(updatedChat.id); + } + clearSummaryGenerating(updatedChat.id); + } // The old membership is only available before the cache write below. const prevStatus = readInfiniteChatsCache(queryClient)?.find( (chat) => chat.id === updatedChat.id, @@ -684,6 +738,12 @@ const AgentsPageLayout: FC = () => { void invalidateChatSearches(queryClient); }, }); + return () => { + dispose(); + for (const timeout of summaryGeneratingTimeouts.values()) { + clearTimeout(timeout); + } + }; }, [queryClient]); useAgentsPageKeybindings({ @@ -744,6 +804,7 @@ const AgentsPageLayout: FC = () => { onToggleSidebarCollapsed: handleToggleSidebarCollapsed, onExpandSidebar: () => setIsSidebarCollapsed(false), onChatReady: () => {}, + summaryGeneratingChatIds, }; return ( diff --git a/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx b/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx index 1a09ff9878fc8..af9ee43cfec17 100644 --- a/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx @@ -32,7 +32,7 @@ const meta: Meta = { }, decorators: [ (Story) => ( -
    +
    ), @@ -153,9 +153,50 @@ export const LongSummary: Story = { export const NoSummary: Story = { args: { summary: null }, + decorators: [ + (Story) => ( +
    + +
    + ), + ], play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await expect(canvas.getByText("No summary yet.")).toBeInTheDocument(); + await expect( + canvas.getByText("Not enough details to summarize."), + ).toBeInTheDocument(); + await expect( + canvas.getByText( + "A recap of your chat will appear here after a few more messages.", + ), + ).toBeInTheDocument(); + await expect( + canvas.queryByText("Generating summary"), + ).not.toBeInTheDocument(); + await expect(canvas.getByText("Created:")).toBeInTheDocument(); + await expect(canvas.getByText("Updated:")).toBeInTheDocument(); + await expect(canvas.getByText("Cost:")).toBeInTheDocument(); + await expect(canvas.getByText("$1.25")).toBeInTheDocument(); + }, +}; + +export const GeneratingSummary: Story = { + args: { summary: null, isGenerating: true }, + decorators: [ + (Story) => ( +
    + +
    + ), + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Generating summary")).toBeInTheDocument(); + await expect( + canvas.queryByText("Not enough details to summarize."), + ).not.toBeInTheDocument(); + await expect(canvas.getByText("Created:")).toBeInTheDocument(); + await expect(canvas.getByText("Cost:")).toBeInTheDocument(); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatSummary.tsx b/site/src/pages/AgentsPage/components/ChatSummary.tsx index 3576fe544cc11..bc58b256caee4 100644 --- a/site/src/pages/AgentsPage/components/ChatSummary.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummary.tsx @@ -1,10 +1,15 @@ +import { MessageSquareDashedIcon } from "lucide-react"; import type { FC, ReactNode } from "react"; import { InlineMarkdown } from "#/components/Markdown/InlineMarkdown"; import { Skeleton } from "#/components/Skeleton/Skeleton"; import { formatCostMicros } from "#/utils/currency"; import { DATE_FORMAT, formatDateTime } from "#/utils/time"; +import { Shimmer } from "./ChatElements"; const EMPTY_VALUE = "-"; +const EMPTY_SUMMARY_TITLE = "Not enough details to summarize."; +const EMPTY_SUMMARY_DESCRIPTION = + "A recap of your chat will appear here after a few more messages."; interface ChatSummaryProps { summary: string | null; @@ -19,6 +24,8 @@ interface ChatSummaryProps { showCost: boolean; /** Subagent summaries are the agent's final report, persisted when it completes, so the empty state reads as pending rather than absent. */ isSubagent?: boolean; + /** True while a root-chat summary is expected to land after a finished turn. */ + isGenerating?: boolean; } export const ChatSummary: FC = ({ @@ -31,6 +38,7 @@ export const ChatSummary: FC = ({ unpricedRequestCount, showCost, isSubagent, + isGenerating, }) => { const trimmedSummary = summary?.trim(); const hasCost = @@ -39,16 +47,29 @@ export const ChatSummary: FC = ({ hasCost && unpricedRequestCount != null && unpricedRequestCount > 0; return ( -
    +
    {trimmedSummary ? ( - ) : ( + ) : isSubagent ? (

    - {isSubagent ? "Summary pending agent completion." : "No summary yet."} + Summary pending agent completion.

    + ) : ( + + Generating summary + + ) : ( + EMPTY_SUMMARY_TITLE + ) + } + description={isGenerating ? undefined : EMPTY_SUMMARY_DESCRIPTION} + /> )} -
    +
    {formatDateTime(createdAt, DATE_FORMAT.MEDIUM_DATE)} @@ -87,6 +108,29 @@ export const ChatSummary: FC = ({ ); }; +interface ChatSummaryEmptyProps { + title: ReactNode; + description?: string; +} + +const ChatSummaryEmpty: FC = ({ + title, + description, +}) => ( +
    +
    + +
    +

    {title}

    +

    + {description} +

    +
    +); + interface ChatSummaryBodyProps { summary: string; } diff --git a/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx b/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx index e6fb51621ae6a..a96b75e9db70b 100644 --- a/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx @@ -22,6 +22,7 @@ type MockRequestOptions = { chatError?: boolean; parentChatId?: string; rootChatId?: string; + status?: TypesGen.ChatStatus; }; const mockRequests = ({ @@ -30,6 +31,7 @@ const mockRequests = ({ chatError, parentChatId, rootChatId, + status, }: MockRequestOptions = {}) => { if (chatError) { spyOn(API.experimental, "getChat").mockRejectedValue( @@ -41,6 +43,7 @@ const mockRequests = ({ summary, ...(parentChatId ? { parent_chat_id: parentChatId } : {}), ...(rootChatId ? { root_chat_id: rootChatId } : {}), + ...(status ? { status } : {}), }); } @@ -103,6 +106,7 @@ export const SubagentSummaryPending: Story = { canvas.getByText("Summary pending agent completion."), ).toBeInTheDocument(); }); + expect(canvas.queryByText("Generating summary")).not.toBeInTheDocument(); }, }; @@ -147,7 +151,63 @@ export const NotVisible: Story = { expect( canvas.queryByText("Should never be fetched."), ).not.toBeInTheDocument(); - expect(canvas.queryByText("No summary yet.")).not.toBeInTheDocument(); + expect( + canvas.queryByText("Not enough details to summarize."), + ).not.toBeInTheDocument(); + }, +}; + +export const NoSummary: Story = { + beforeEach: () => mockRequests(), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect( + canvas.getByText("Not enough details to summarize."), + ).toBeInTheDocument(); + }); + expect( + canvas.getByText( + "A recap of your chat will appear here after a few more messages.", + ), + ).toBeInTheDocument(); + expect(canvas.queryByText("Generating summary")).not.toBeInTheDocument(); + expect(canvas.getByText("Created:")).toBeInTheDocument(); + expect(canvas.getByText("Updated:")).toBeInTheDocument(); + expect(canvas.getByText("Cost:")).toBeInTheDocument(); + expect(canvas.getByText("$1.25")).toBeInTheDocument(); + }, +}; + +export const GeneratingSummary: Story = { + args: { isGenerating: true }, + beforeEach: () => mockRequests({ status: "waiting" }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect(canvas.getByText("Generating summary")).toBeInTheDocument(); + }); + expect( + canvas.queryByText("Not enough details to summarize."), + ).not.toBeInTheDocument(); + expect(canvas.getByText("Created:")).toBeInTheDocument(); + expect(canvas.getByText("Cost:")).toBeInTheDocument(); + }, +}; + +// A short completed chat (for example, a single "test" prompt) is waiting but +// never emits chat_summary_generating, so it must keep the empty state. +export const ShortChatDoesNotGenerateSummary: Story = { + beforeEach: () => mockRequests({ status: "waiting" }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect( + canvas.getByText("Not enough details to summarize."), + ).toBeInTheDocument(); + }); + expect(canvas.queryByText("Generating summary")).not.toBeInTheDocument(); + expect(canvas.getByText("Cost:")).toBeInTheDocument(); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx b/site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx index 7f4e0b0bedef4..b545a39904e82 100644 --- a/site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx @@ -10,11 +10,14 @@ type ChatSummaryPanelProps = { chatId: string; /** Gate reads on tab visibility so the chat and cost queries don't run while the tab is hidden. */ isVisible: boolean; + /** Set only after the server reports that summary generation started. */ + isGenerating?: boolean; }; export const ChatSummaryPanel: FC = ({ chatId, isVisible, + isGenerating, }) => { const showCost = Boolean(useFeatureVisibility().aibridge); const chatQuery = useQuery({ ...chat(chatId), enabled: isVisible }); @@ -28,12 +31,17 @@ export const ChatSummaryPanel: FC = ({ let content: ReactNode = null; if (chatQuery.isError) { - content = ; + content = ( +
    + +
    + ); } else if (chatData) { content = ( = ({ } return ( -
    +
    {content}
    ); From 2feb05c4451ea1e78d9bfdecb6cc0718af48a17b Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 27 Aug 2026 15:45:08 +0000 Subject: [PATCH 13/27] fix: address chat summary review feedback --- coderd/apidoc/swagger.json | 2 + codersdk/chats.go | 10 ++-- docs/reference/api/schemas.md | 4 +- site/src/@types/storybook.d.ts | 4 +- .../AgentsPage/AgentsPageLayout.stories.tsx | 51 ++++++++++++++++++- .../components/ChatSummary.stories.tsx | 3 +- .../AgentsPage/components/ChatSummary.tsx | 8 +-- .../components/ChatSummaryPanel.stories.tsx | 19 +++++-- .../components/ChatSummaryPanel.tsx | 11 +++- site/src/testHelpers/storybook.tsx | 19 ++++--- 10 files changed, 106 insertions(+), 25 deletions(-) diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 4a657b17c1483..155326a770613 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -18175,6 +18175,7 @@ "status_change", "summary_change", "chat_summary_change", + "chat_summary_generating", "title_change", "created", "deleted", @@ -18186,6 +18187,7 @@ "ChatWatchEventKindStatusChange", "ChatWatchEventKindSummaryChange", "ChatWatchEventKindChatSummaryChange", + "ChatWatchEventKindChatSummaryGenerating", "ChatWatchEventKindTitleChange", "ChatWatchEventKindCreated", "ChatWatchEventKindDeleted", diff --git a/codersdk/chats.go b/codersdk/chats.go index 18f2f98e2b936..f12d90ba4aa36 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -1866,13 +1866,13 @@ const ( // ChatWatchEventKindChatSummaryChange carries the persisted whole-chat // summary. It is distinct from SummaryChange (bound to last_turn_summary) so // the frontend updates one field without disturbing the other. - ChatWatchEventKindChatSummaryChange ChatWatchEventKind = "chat_summary_change" + ChatWatchEventKindChatSummaryChange ChatWatchEventKind = "chat_summary_change" ChatWatchEventKindChatSummaryGenerating ChatWatchEventKind = "chat_summary_generating" ChatWatchEventKindTitleChange ChatWatchEventKind = "title_change" - ChatWatchEventKindCreated ChatWatchEventKind = "created" - ChatWatchEventKindDeleted ChatWatchEventKind = "deleted" - ChatWatchEventKindDiffStatusChange ChatWatchEventKind = "diff_status_change" - ChatWatchEventKindActionRequired ChatWatchEventKind = "action_required" + ChatWatchEventKindCreated ChatWatchEventKind = "created" + ChatWatchEventKindDeleted ChatWatchEventKind = "deleted" + ChatWatchEventKindDiffStatusChange ChatWatchEventKind = "diff_status_change" + ChatWatchEventKindActionRequired ChatWatchEventKind = "action_required" // ChatWatchEventKindContextDirty signals that the chat's pinned // workspace context changed: it drifted from the agent's latest // pushed snapshot, or hydration first populated it (a first-turn diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index b778efe9789c2..a8cbe595cace4 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -5341,8 +5341,8 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in #### Enumerated Values -| Value(s) | -|----------------------------------------------------------------------------------------------------------------------------------------------------------| +| Value(s) | +|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `action_required`, `chat_summary_change`, `chat_summary_generating`, `context_dirty`, `created`, `deleted`, `diff_status_change`, `status_change`, `summary_change`, `title_change` | ## codersdk.ClusterConfig diff --git a/site/src/@types/storybook.d.ts b/site/src/@types/storybook.d.ts index 3c34fdc352501..1c125771d15c0 100644 --- a/site/src/@types/storybook.d.ts +++ b/site/src/@types/storybook.d.ts @@ -13,8 +13,8 @@ import type { ReactRouterAddonStoryParameters } from "storybook-addon-remix-reac declare module "@storybook/react-vite" { type WebSocketEvent = - | { event: "message"; data: string } - | { event: "open" | "error" | "close" }; + | { event: "message"; data: string; delayMs?: number } + | { event: "open" | "error" | "close"; delayMs?: number }; interface Parameters { features?: (FeatureName | ({ name: FeatureName } & Partial))[]; experiments?: Experiments; diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx index 03c60fd5d5fe0..a721a3e5a3933 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx @@ -56,6 +56,7 @@ import { LEFT_SIDEBAR_STORAGE_KEY, } from "./components/ChatsSidebar/sidebarWidth"; import { ChatTopBar } from "./components/ChatTopBar"; +import { clearPersistedSidebarTabId } from "./utils/sidebarTabStorage"; const defaultModelID = "model-config-1"; @@ -1056,9 +1057,14 @@ const watchedChatQueries = (chat: Chat) => [ }, ]; -const chatWatchEvent = (kind: TypesGen.ChatWatchEventKind, chat: Chat) => ({ +const chatWatchEvent = ( + kind: TypesGen.ChatWatchEventKind, + chat: Chat, + delayMs = 0, +) => ({ event: "message" as const, data: JSON.stringify({ kind, chat } satisfies TypesGen.ChatWatchEvent), + delayMs, }); const watchedChatPageParameters = ( @@ -1085,6 +1091,49 @@ const mockAgentChatPageAPIs = () => { return () => localStorage.removeItem(RIGHT_PANEL_OPEN_KEY); }; +export const SummaryWatchEventsUpdateOpenPanel: Story = { + decorators: [withProxyProvider()], + beforeEach: () => { + mockChats([watchedChat()]); + const cleanup = mockAgentChatPageAPIs(); + clearPersistedSidebarTabId(WATCHED_CHAT_ID); + localStorage.setItem(RIGHT_PANEL_OPEN_KEY, "true"); + return () => { + clearPersistedSidebarTabId(WATCHED_CHAT_ID); + cleanup(); + }; + }, + parameters: watchedChatPageParameters(watchedChat(), [ + chatWatchEvent("chat_summary_generating", watchedChat(), 500), + chatWatchEvent( + "chat_summary_change", + watchedChat({ summary: "Generated summary from the watch event." }), + 750, + ), + ]), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const summaryPanel = await canvas.findByRole("tabpanel", { + name: "Summary", + }); + const summary = within(summaryPanel); + expect( + await summary.findByText("Not enough details to summarize."), + ).toBeVisible(); + + const status = await summary.findByRole("status"); + expect(status).toHaveTextContent("Generating summary"); + expect( + summary.queryByText("Not enough details to summarize."), + ).not.toBeInTheDocument(); + + expect( + await summary.findByText("Generated summary from the watch event."), + ).toBeVisible(); + expect(summary.queryByRole("status")).not.toBeInTheDocument(); + }, +}; + export const ArchiveWatchEventKeepsOpenChatMounted: Story = { decorators: [withProxyProvider()], beforeEach: () => { diff --git a/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx b/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx index af9ee43cfec17..d0bf6cf81c8b6 100644 --- a/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx @@ -191,7 +191,8 @@ export const GeneratingSummary: Story = { ], play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await expect(canvas.getByText("Generating summary")).toBeInTheDocument(); + const status = canvas.getByRole("status"); + await expect(status).toHaveTextContent("Generating summary"); await expect( canvas.queryByText("Not enough details to summarize."), ).not.toBeInTheDocument(); diff --git a/site/src/pages/AgentsPage/components/ChatSummary.tsx b/site/src/pages/AgentsPage/components/ChatSummary.tsx index bc58b256caee4..0cbb08a225598 100644 --- a/site/src/pages/AgentsPage/components/ChatSummary.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummary.tsx @@ -58,9 +58,11 @@ export const ChatSummary: FC = ({ - Generating summary - + + + Generating summary + + ) : ( EMPTY_SUMMARY_TITLE ) diff --git a/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx b/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx index a96b75e9db70b..4b393b6b6fccb 100644 --- a/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx @@ -71,6 +71,20 @@ const meta: Meta = { export default meta; type Story = StoryObj; +export const Loading: Story = { + beforeEach: () => { + spyOn(API.experimental, "getChat").mockImplementation( + () => new Promise(() => {}), + ); + spyOn(API.experimental, "getChatCost").mockResolvedValue(mockCost); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByLabelText("Loading summary")).toBeVisible(); + expect(API.experimental.getChatCost).not.toHaveBeenCalled(); + }, +}; + export const WithSummary: Story = { beforeEach: () => mockRequests({ @@ -184,9 +198,8 @@ export const GeneratingSummary: Story = { beforeEach: () => mockRequests({ status: "waiting" }), play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await waitFor(() => { - expect(canvas.getByText("Generating summary")).toBeInTheDocument(); - }); + const status = await canvas.findByRole("status"); + expect(status).toHaveTextContent("Generating summary"); expect( canvas.queryByText("Not enough details to summarize."), ).not.toBeInTheDocument(); diff --git a/site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx b/site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx index b545a39904e82..182c67be1df04 100644 --- a/site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx @@ -2,6 +2,7 @@ import type { FC, ReactNode } from "react"; import { useQuery } from "react-query"; import { chat, chatCost } from "#/api/queries/chats"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import { Skeleton } from "#/components/Skeleton/Skeleton"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; import { getChatCostTreeID } from "./ChatConversation/chatHelpers"; import { ChatSummary } from "./ChatSummary"; @@ -30,7 +31,15 @@ export const ChatSummaryPanel: FC = ({ }); let content: ReactNode = null; - if (chatQuery.isError) { + if (chatQuery.isLoading) { + content = ( +
    + + + +
    + ); + } else if (chatQuery.isError) { content = (
    diff --git a/site/src/testHelpers/storybook.tsx b/site/src/testHelpers/storybook.tsx index fbb80d54a61fe..33f168d651f58 100644 --- a/site/src/testHelpers/storybook.tsx +++ b/site/src/testHelpers/storybook.tsx @@ -88,6 +88,8 @@ type CallbackFn = (ev?: MessageEvent) => void; // "/api/experimental/chats/": [{ event: "message", data: "..." }], // "/api/experimental/workspaceagents/": [{ event: "message", data: "..." }], // } +// +// Events may set delayMs to defer delivery after listeners are registered. export const withWebSocket = (Story: FC, { parameters }: StoryContext) => { const param = parameters.webSocket; @@ -135,13 +137,16 @@ export const withWebSocket = (Story: FC, { parameters }: StoryContext) => { clearTimeout(this.#callEventsDelay); this.#callEventsDelay = window.setTimeout(() => { for (const entry of events) { - const callback = this.#listeners.get(entry.event); - - if (callback) { - entry.event === "message" - ? callback({ data: entry.data }) - : callback(); - } + const dispatch = () => { + const callback = this.#listeners.get(entry.event); + + if (callback) { + entry.event === "message" + ? callback({ data: entry.data }) + : callback(); + } + }; + window.setTimeout(dispatch, entry.delayMs ?? 0); } }, 0); } From 68d1b485de9e91f4c4d5cdd1928230197f7e6a92 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 27 Aug 2026 16:45:27 +0000 Subject: [PATCH 14/27] fix: recover active chat summary generation --- coderd/database/dbauthz/dbauthz.go | 26 ++++ coderd/database/dbauthz/dbauthz_test.go | 26 ++++ coderd/database/dbmetrics/querymetrics.go | 24 +++ coderd/database/dbmock/dbmock.go | 44 ++++++ coderd/database/dump.sql | 11 ++ coderd/database/foreign_key_constraint.go | 1 + .../migrations/000551_chat_summary.down.sql | 2 + .../migrations/000551_chat_summary.up.sql | 7 + coderd/database/models.go | 5 + coderd/database/querier.go | 7 +- coderd/database/querier_test.go | 47 +++++- coderd/database/queries.sql.go | 147 +++++++++++++++++- coderd/database/queries/chats.sql | 39 ++++- coderd/database/unique_constraint.go | 1 + coderd/exp_chats.go | 34 +++- coderd/exp_chats_test.go | 32 ++++ coderd/x/chatd/chatd.go | 47 +++++- coderd/x/chatd/chatd_internal_test.go | 6 +- codersdk/chats.go | 4 + .../AgentsPage/AgentsPageLayout.stories.tsx | 14 +- .../components/ChatSummary.stories.tsx | 9 ++ .../AgentsPage/components/ChatSummary.tsx | 6 + 22 files changed, 512 insertions(+), 27 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 01c525c677c4a..41393efbc297c 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1988,6 +1988,17 @@ func (q *querier) CleanupDeletedMCPServerIDsFromChats(ctx context.Context) error return q.db.CleanupDeletedMCPServerIDsFromChats(ctx) } +func (q *querier) ClearChatSummaryGeneration(ctx context.Context, arg database.ClearChatSummaryGenerationParams) error { + chat, err := q.db.GetChatByID(ctx, arg.ID) + if err != nil { + return err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return err + } + return q.db.ClearChatSummaryGeneration(ctx, arg) +} + func (q *querier) CountAIBridgeSessions(ctx context.Context, arg database.CountAIBridgeSessionsParams) (int64, error) { prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceAibridgeInterception.Type) if err != nil { @@ -3024,6 +3035,10 @@ func (q *querier) GetActiveAISeatCount(ctx context.Context) (int64, error) { return q.db.GetActiveAISeatCount(ctx) } +func (q *querier) GetActiveChatSummaryGenerationsByOwnerID(ctx context.Context, arg database.GetActiveChatSummaryGenerationsByOwnerIDParams) ([]database.Chat, error) { + return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetActiveChatSummaryGenerationsByOwnerID)(ctx, arg) +} + func (q *querier) GetActiveChatsByAgentID(ctx context.Context, agentID uuid.UUID) ([]database.Chat, error) { return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetActiveChatsByAgentID)(ctx, agentID) } @@ -7315,6 +7330,17 @@ func (q *querier) SoftDeleteWorkspaceAgentsByWorkspaceID(ctx context.Context, wo return q.db.SoftDeleteWorkspaceAgentsByWorkspaceID(ctx, workspaceID) } +func (q *querier) StartChatSummaryGeneration(ctx context.Context, id uuid.UUID) (time.Time, error) { + chat, err := q.db.GetChatByID(ctx, id) + if err != nil { + return time.Time{}, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return time.Time{}, err + } + return q.db.StartChatSummaryGeneration(ctx, id) +} + func (q *querier) TouchChatDebugRunUpdatedAt(ctx context.Context, arg database.TouchChatDebugRunUpdatedAtParams) error { chat, err := q.db.GetChatByID(ctx, arg.ChatID) if err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 4050b9174247e..b767fe9df89ae 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -992,6 +992,15 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().GetActiveChatsByAgentID(gomock.Any(), agentID).Return([]database.Chat{chat}, nil).AnyTimes() check.Args(agentID).Asserts(chat, policy.ActionRead).Returns([]database.Chat{chat}) })) + s.Run("GetActiveChatSummaryGenerationsByOwnerID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + arg := database.GetActiveChatSummaryGenerationsByOwnerIDParams{ + OwnerID: chat.OwnerID, + MaxAgeSeconds: 120, + } + dbm.EXPECT().GetActiveChatSummaryGenerationsByOwnerID(gomock.Any(), arg).Return([]database.Chat{chat}, nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionRead).Returns([]database.Chat{chat}) + })) s.Run("SoftDeleteContextFileMessages", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() @@ -1950,6 +1959,23 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().UpdateChatLastTurnSummary(gomock.Any(), arg).Return(int64(1), nil).AnyTimes() check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(int64(1)) })) + s.Run("StartChatSummaryGeneration", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + startedAt := time.Now() + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().StartChatSummaryGeneration(gomock.Any(), chat.ID).Return(startedAt, nil).AnyTimes() + check.Args(chat.ID).Asserts(chat, policy.ActionUpdate).Returns(startedAt) + })) + s.Run("ClearChatSummaryGeneration", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + arg := database.ClearChatSummaryGenerationParams{ + ID: chat.ID, + GenerationStartedAt: time.Now(), + } + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().ClearChatSummaryGeneration(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns() + })) s.Run("UpdateChatSummary", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) arg := database.UpdateChatSummaryParams{ diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index c500907e35d03..4465029478787 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -312,6 +312,14 @@ func (m queryMetricsStore) CleanupDeletedMCPServerIDsFromChats(ctx context.Conte return r0 } +func (m queryMetricsStore) ClearChatSummaryGeneration(ctx context.Context, arg database.ClearChatSummaryGenerationParams) error { + start := time.Now() + r0 := m.s.ClearChatSummaryGeneration(ctx, arg) + m.queryLatencies.WithLabelValues("ClearChatSummaryGeneration").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ClearChatSummaryGeneration").Inc() + return r0 +} + func (m queryMetricsStore) CountAIBridgeSessions(ctx context.Context, arg database.CountAIBridgeSessionsParams) (int64, error) { start := time.Now() r0, r1 := m.s.CountAIBridgeSessions(ctx, arg) @@ -1288,6 +1296,14 @@ func (m queryMetricsStore) GetActiveAISeatCount(ctx context.Context) (int64, err return r0, r1 } +func (m queryMetricsStore) GetActiveChatSummaryGenerationsByOwnerID(ctx context.Context, arg database.GetActiveChatSummaryGenerationsByOwnerIDParams) ([]database.Chat, error) { + start := time.Now() + r0, r1 := m.s.GetActiveChatSummaryGenerationsByOwnerID(ctx, arg) + m.queryLatencies.WithLabelValues("GetActiveChatSummaryGenerationsByOwnerID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetActiveChatSummaryGenerationsByOwnerID").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetActiveChatsByAgentID(ctx context.Context, agentID uuid.UUID) ([]database.Chat, error) { start := time.Now() r0, r1 := m.s.GetActiveChatsByAgentID(ctx, agentID) @@ -5128,6 +5144,14 @@ func (m queryMetricsStore) SoftDeleteWorkspaceAgentsByWorkspaceID(ctx context.Co return r0 } +func (m queryMetricsStore) StartChatSummaryGeneration(ctx context.Context, id uuid.UUID) (time.Time, error) { + start := time.Now() + r0, r1 := m.s.StartChatSummaryGeneration(ctx, id) + m.queryLatencies.WithLabelValues("StartChatSummaryGeneration").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "StartChatSummaryGeneration").Inc() + return r0, r1 +} + func (m queryMetricsStore) TouchChatDebugRunUpdatedAt(ctx context.Context, arg database.TouchChatDebugRunUpdatedAtParams) error { start := time.Now() r0 := m.s.TouchChatDebugRunUpdatedAt(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 71384ea7b7c87..bd5129f7e3382 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -422,6 +422,20 @@ func (mr *MockStoreMockRecorder) CleanupDeletedMCPServerIDsFromChats(ctx any) *g return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanupDeletedMCPServerIDsFromChats", reflect.TypeOf((*MockStore)(nil).CleanupDeletedMCPServerIDsFromChats), ctx) } +// ClearChatSummaryGeneration mocks base method. +func (m *MockStore) ClearChatSummaryGeneration(ctx context.Context, arg database.ClearChatSummaryGenerationParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ClearChatSummaryGeneration", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// ClearChatSummaryGeneration indicates an expected call of ClearChatSummaryGeneration. +func (mr *MockStoreMockRecorder) ClearChatSummaryGeneration(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClearChatSummaryGeneration", reflect.TypeOf((*MockStore)(nil).ClearChatSummaryGeneration), ctx, arg) +} + // CountAIBridgeSessions mocks base method. func (m *MockStore) CountAIBridgeSessions(ctx context.Context, arg database.CountAIBridgeSessionsParams) (int64, error) { m.ctrl.T.Helper() @@ -2264,6 +2278,21 @@ func (mr *MockStoreMockRecorder) GetActiveAISeatCount(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveAISeatCount", reflect.TypeOf((*MockStore)(nil).GetActiveAISeatCount), ctx) } +// GetActiveChatSummaryGenerationsByOwnerID mocks base method. +func (m *MockStore) GetActiveChatSummaryGenerationsByOwnerID(ctx context.Context, arg database.GetActiveChatSummaryGenerationsByOwnerIDParams) ([]database.Chat, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetActiveChatSummaryGenerationsByOwnerID", ctx, arg) + ret0, _ := ret[0].([]database.Chat) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetActiveChatSummaryGenerationsByOwnerID indicates an expected call of GetActiveChatSummaryGenerationsByOwnerID. +func (mr *MockStoreMockRecorder) GetActiveChatSummaryGenerationsByOwnerID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveChatSummaryGenerationsByOwnerID", reflect.TypeOf((*MockStore)(nil).GetActiveChatSummaryGenerationsByOwnerID), ctx, arg) +} + // GetActiveChatsByAgentID mocks base method. func (m *MockStore) GetActiveChatsByAgentID(ctx context.Context, agentID uuid.UUID) ([]database.Chat, error) { m.ctrl.T.Helper() @@ -9736,6 +9765,21 @@ func (mr *MockStoreMockRecorder) SoftDeleteWorkspaceAgentsByWorkspaceID(ctx, wor return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SoftDeleteWorkspaceAgentsByWorkspaceID", reflect.TypeOf((*MockStore)(nil).SoftDeleteWorkspaceAgentsByWorkspaceID), ctx, workspaceID) } +// StartChatSummaryGeneration mocks base method. +func (m *MockStore) StartChatSummaryGeneration(ctx context.Context, id uuid.UUID) (time.Time, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StartChatSummaryGeneration", ctx, id) + ret0, _ := ret[0].(time.Time) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// StartChatSummaryGeneration indicates an expected call of StartChatSummaryGeneration. +func (mr *MockStoreMockRecorder) StartChatSummaryGeneration(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartChatSummaryGeneration", reflect.TypeOf((*MockStore)(nil).StartChatSummaryGeneration), ctx, id) +} + // TouchChatDebugRunUpdatedAt mocks base method. func (m *MockStore) TouchChatDebugRunUpdatedAt(ctx context.Context, arg database.TouchChatDebugRunUpdatedAtParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 905b4d0082352..f796a3893ec80 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -2133,6 +2133,11 @@ CREATE SEQUENCE chat_queued_messages_id_seq ALTER SEQUENCE chat_queued_messages_id_seq OWNED BY chat_queued_messages.id; +CREATE TABLE chat_summary_generations ( + chat_id uuid NOT NULL, + started_at timestamp with time zone DEFAULT now() NOT NULL +); + CREATE TABLE chat_usage_limit_config ( id bigint NOT NULL, singleton boolean DEFAULT true NOT NULL, @@ -4438,6 +4443,9 @@ ALTER TABLE ONLY chat_organization_model_overrides ALTER TABLE ONLY chat_queued_messages ADD CONSTRAINT chat_queued_messages_pkey PRIMARY KEY (id); +ALTER TABLE ONLY chat_summary_generations + ADD CONSTRAINT chat_summary_generations_pkey PRIMARY KEY (chat_id); + ALTER TABLE ONLY chat_usage_limit_config ADD CONSTRAINT chat_usage_limit_config_pkey PRIMARY KEY (id); @@ -5323,6 +5331,9 @@ ALTER TABLE ONLY chat_organization_model_overrides ALTER TABLE ONLY chat_queued_messages ADD CONSTRAINT chat_queued_messages_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; +ALTER TABLE ONLY chat_summary_generations + ADD CONSTRAINT chat_summary_generations_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; + ALTER TABLE ONLY chat_user_model_overrides ADD CONSTRAINT chat_user_model_overrides_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index 251ce1aec5c36..99f5e8502e0b4 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -33,6 +33,7 @@ const ( ForeignKeyChatOrganizationModelOverridesOrganizationID ForeignKeyConstraint = "chat_organization_model_overrides_organization_id_fkey" // ALTER TABLE ONLY chat_organization_model_overrides ADD CONSTRAINT chat_organization_model_overrides_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; ForeignKeyChatOrganizationModelOverridesOrganizationModelConfigFke ForeignKeyConstraint = "chat_organization_model_overrides_organization_model_config_fke" // ALTER TABLE ONLY chat_organization_model_overrides ADD CONSTRAINT chat_organization_model_overrides_organization_model_config_fke FOREIGN KEY (organization_id, model_config_id) REFERENCES chat_model_configs(organization_id, id); ForeignKeyChatQueuedMessagesChatID ForeignKeyConstraint = "chat_queued_messages_chat_id_fkey" // ALTER TABLE ONLY chat_queued_messages ADD CONSTRAINT chat_queued_messages_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; + ForeignKeyChatSummaryGenerationsChatID ForeignKeyConstraint = "chat_summary_generations_chat_id_fkey" // ALTER TABLE ONLY chat_summary_generations ADD CONSTRAINT chat_summary_generations_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; ForeignKeyChatUserModelOverridesOrganizationID ForeignKeyConstraint = "chat_user_model_overrides_organization_id_fkey" // ALTER TABLE ONLY chat_user_model_overrides ADD CONSTRAINT chat_user_model_overrides_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; ForeignKeyChatUserModelOverridesOrganizationModelConfig ForeignKeyConstraint = "chat_user_model_overrides_organization_model_config_fkey" // ALTER TABLE ONLY chat_user_model_overrides ADD CONSTRAINT chat_user_model_overrides_organization_model_config_fkey FOREIGN KEY (organization_id, model_config_id) REFERENCES chat_model_configs(organization_id, id); ForeignKeyChatUserModelOverridesUserID ForeignKeyConstraint = "chat_user_model_overrides_user_id_fkey" // ALTER TABLE ONLY chat_user_model_overrides ADD CONSTRAINT chat_user_model_overrides_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000551_chat_summary.down.sql b/coderd/database/migrations/000551_chat_summary.down.sql index 4b56bc9b93af8..f64e3c0622604 100644 --- a/coderd/database/migrations/000551_chat_summary.down.sql +++ b/coderd/database/migrations/000551_chat_summary.down.sql @@ -2,6 +2,8 @@ -- the summary columns, matching the 000549 chats_expanded definition. DROP VIEW IF EXISTS chats_expanded; +DROP TABLE chat_summary_generations; + ALTER TABLE chats DROP COLUMN summary, DROP COLUMN summary_generated_at; diff --git a/coderd/database/migrations/000551_chat_summary.up.sql b/coderd/database/migrations/000551_chat_summary.up.sql index 3b3e8a407ff27..21f609c47d40f 100644 --- a/coderd/database/migrations/000551_chat_summary.up.sql +++ b/coderd/database/migrations/000551_chat_summary.up.sql @@ -4,6 +4,13 @@ ALTER TABLE chats ADD COLUMN summary TEXT, ADD COLUMN summary_generated_at TIMESTAMPTZ; +-- Active summary generation is tracked separately so chat reads stay stable +-- while late watch subscribers can recover the transient loading state. +CREATE TABLE chat_summary_generations ( + chat_id UUID PRIMARY KEY REFERENCES chats(id) ON DELETE CASCADE, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + -- Recreate chats_expanded: its explicit column list hides new columns otherwise. DROP VIEW IF EXISTS chats_expanded; diff --git a/coderd/database/models.go b/coderd/database/models.go index 3e1464dc07f2c..19ef39c9d873c 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -5287,6 +5287,11 @@ type ChatQueuedMessage struct { ReasoningEffort NullChatReasoningEffort `db:"reasoning_effort" json:"reasoning_effort"` } +type ChatSummaryGeneration struct { + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` + StartedAt time.Time `db:"started_at" json:"started_at"` +} + type ChatTable struct { ID uuid.UUID `db:"id" json:"id"` OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index f5d3a4ccf7fd4..a68435f4c14d6 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -93,6 +93,7 @@ type sqlcQuerier interface { CleanTailnetLostPeers(ctx context.Context) error CleanTailnetTunnels(ctx context.Context) error CleanupDeletedMCPServerIDsFromChats(ctx context.Context) error + ClearChatSummaryGeneration(ctx context.Context, arg ClearChatSummaryGenerationParams) error CountAIBridgeSessions(ctx context.Context, arg CountAIBridgeSessionsParams) (int64, error) CountAuditLogs(ctx context.Context, arg CountAuditLogsParams) (int64, error) // Excluding the candidate keeps ownership takeover capacity-neutral. @@ -366,6 +367,7 @@ type sqlcQuerier interface { GetAPIKeysByUserID(ctx context.Context, arg GetAPIKeysByUserIDParams) ([]APIKey, error) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error) GetActiveAISeatCount(ctx context.Context) (int64, error) + GetActiveChatSummaryGenerationsByOwnerID(ctx context.Context, arg GetActiveChatSummaryGenerationsByOwnerIDParams) ([]Chat, error) GetActiveChatsByAgentID(ctx context.Context, agentID uuid.UUID) ([]Chat, error) GetActivePresetPrebuildSchedules(ctx context.Context) ([]TemplateVersionPresetPrebuildSchedule, error) GetActiveUserCount(ctx context.Context, includeSystem bool) (int64, error) @@ -1396,6 +1398,7 @@ type sqlcQuerier interface { // Agent context rows are hard-deleted for the same reason as in // SoftDeletePriorWorkspaceAgents. SoftDeleteWorkspaceAgentsByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) error + StartChatSummaryGeneration(ctx context.Context, id uuid.UUID) (time.Time, error) // Overrides updated_at on the parent run without touching any // other column. Used by tests that need to stamp a run with a // specific timestamp after the InsertChatDebugStep CTE has @@ -1506,7 +1509,9 @@ type sqlcQuerier interface { UpdateChatRetryState(ctx context.Context, arg UpdateChatRetryStateParams) (Chat, error) UpdateChatStatus(ctx context.Context, arg UpdateChatStatusParams) (Chat, error) // The history_version fence lets background summary writes ignore worker-only - // updates while losing to newer message history. + // updates while losing to newer message history. Root summary workers also pass + // their generation marker so an older overlapping worker cannot overwrite a + // newer attempt. UpdateChatSummary(ctx context.Context, arg UpdateChatSummaryParams) (int64, error) UpdateChatTitleByID(ctx context.Context, arg UpdateChatTitleByIDParams) (Chat, error) UpdateChatWorkspaceBinding(ctx context.Context, arg UpdateChatWorkspaceBindingParams) (Chat, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 32d5bf246979f..159a171c6f3bb 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -16175,10 +16175,38 @@ func TestUpdateChatSummary(t *testing.T) { require.False(t, chat.Summary.Valid) require.False(t, chat.SummaryGeneratedAt.Valid) + generationStartedAt, err := db.StartChatSummaryGeneration(ctx, chat.ID) + require.NoError(t, err) + + activeGenerations, err := db.GetActiveChatSummaryGenerationsByOwnerID(ctx, database.GetActiveChatSummaryGenerationsByOwnerIDParams{ + OwnerID: owner.ID, + MaxAgeSeconds: 60, + }) + require.NoError(t, err) + require.Len(t, activeGenerations, 1) + require.Equal(t, chat.ID, activeGenerations[0].ID) + + expiredGenerations, err := db.GetActiveChatSummaryGenerationsByOwnerID(ctx, database.GetActiveChatSummaryGenerationsByOwnerIDParams{ + OwnerID: owner.ID, + MaxAgeSeconds: 0, + }) + require.NoError(t, err) + require.Empty(t, expiredGenerations) + affected, err := db.UpdateChatSummary(ctx, database.UpdateChatSummaryParams{ - ID: chat.ID, - ExpectedHistoryVersion: chat.HistoryVersion, - Summary: sql.NullString{String: "Implemented the whole-chat summary feature.", Valid: true}, + ID: chat.ID, + ExpectedHistoryVersion: chat.HistoryVersion, + ExpectedGenerationStartedAt: sql.NullTime{Time: generationStartedAt.Add(-time.Second), Valid: true}, + Summary: sql.NullString{String: "stale generation", Valid: true}, + }) + require.NoError(t, err) + require.Zero(t, affected) + + affected, err = db.UpdateChatSummary(ctx, database.UpdateChatSummaryParams{ + ID: chat.ID, + ExpectedHistoryVersion: chat.HistoryVersion, + ExpectedGenerationStartedAt: sql.NullTime{Time: generationStartedAt, Valid: true}, + Summary: sql.NullString{String: "Implemented the whole-chat summary feature.", Valid: true}, }) require.NoError(t, err) require.EqualValues(t, 1, affected) @@ -16189,6 +16217,19 @@ func TestUpdateChatSummary(t *testing.T) { require.True(t, fetched.SummaryGeneratedAt.Valid) require.Equal(t, chat.UpdatedAt, fetched.UpdatedAt) + err = db.ClearChatSummaryGeneration(ctx, database.ClearChatSummaryGenerationParams{ + ID: chat.ID, + GenerationStartedAt: generationStartedAt, + }) + require.NoError(t, err) + + activeGenerations, err = db.GetActiveChatSummaryGenerationsByOwnerID(ctx, database.GetActiveChatSummaryGenerationsByOwnerIDParams{ + OwnerID: owner.ID, + MaxAgeSeconds: 60, + }) + require.NoError(t, err) + require.Empty(t, activeGenerations) + affected, err = db.UpdateChatSummary(ctx, database.UpdateChatSummaryParams{ ID: chat.ID, ExpectedHistoryVersion: chat.HistoryVersion, diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 5d5e49d5a2716..1ace21dc22788 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -7492,6 +7492,23 @@ func (q *sqlQuerier) BatchUpsertChatHeartbeats(ctx context.Context, arg BatchUps return err } +const clearChatSummaryGeneration = `-- name: ClearChatSummaryGeneration :exec +DELETE FROM chat_summary_generations +WHERE + chat_id = $1::uuid + AND started_at = $2::timestamptz +` + +type ClearChatSummaryGenerationParams struct { + ID uuid.UUID `db:"id" json:"id"` + GenerationStartedAt time.Time `db:"generation_started_at" json:"generation_started_at"` +} + +func (q *sqlQuerier) ClearChatSummaryGeneration(ctx context.Context, arg ClearChatSummaryGenerationParams) error { + _, err := q.db.ExecContext(ctx, clearChatSummaryGeneration, arg.ID, arg.GenerationStartedAt) + return err +} + const countChatCapacityActiveByPool = `-- name: CountChatCapacityActiveByPool :one SELECT COUNT(*) FILTER (WHERE c.parent_chat_id IS NULL)::bigint AS active_root_count, @@ -7696,6 +7713,93 @@ func (q *sqlQuerier) DeleteStaleChatHeartbeats(ctx context.Context, staleSeconds return result.RowsAffected() } +const getActiveChatSummaryGenerationsByOwnerID = `-- name: GetActiveChatSummaryGenerationsByOwnerID :many +SELECT c.id, c.owner_id, c.workspace_id, c.title, c.status, c.worker_id, c.started_at, c.heartbeat_at, c.created_at, c.updated_at, c.parent_chat_id, c.root_chat_id, c.last_model_config_id, c.last_reasoning_effort, c.archived, c.last_error, c.mode, c.mcp_server_ids, c.labels, c.build_id, c.agent_id, c.pin_order, c.last_read_message_id, c.dynamic_tools, c.organization_id, c.plan_mode, c.client_type, c.last_turn_summary, c.summary, c.summary_generated_at, c.snapshot_version, c.history_version, c.queue_version, c.generation_attempt, c.retry_state, c.retry_state_version, c.runner_id, c.requires_action_deadline_at, c.user_acl, c.group_acl, c.owner_username, c.owner_name, c.context_aggregate_hash, c.context_dirty_since, c.context_dirty_resources, c.context_error, c.compaction_requested_at +FROM chat_summary_generations g +JOIN chats_expanded c ON c.id = g.chat_id +WHERE + c.owner_id = $1::uuid + AND c.parent_chat_id IS NULL + AND g.started_at > NOW() - (INTERVAL '1 second' * $2::int) +ORDER BY g.started_at +` + +type GetActiveChatSummaryGenerationsByOwnerIDParams struct { + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + MaxAgeSeconds int32 `db:"max_age_seconds" json:"max_age_seconds"` +} + +func (q *sqlQuerier) GetActiveChatSummaryGenerationsByOwnerID(ctx context.Context, arg GetActiveChatSummaryGenerationsByOwnerIDParams) ([]Chat, error) { + rows, err := q.db.QueryContext(ctx, getActiveChatSummaryGenerationsByOwnerID, arg.OwnerID, arg.MaxAgeSeconds) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Chat + for rows.Next() { + var i Chat + if err := rows.Scan( + &i.ID, + &i.OwnerID, + &i.WorkspaceID, + &i.Title, + &i.Status, + &i.WorkerID, + &i.StartedAt, + &i.HeartbeatAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.ParentChatID, + &i.RootChatID, + &i.LastModelConfigID, + &i.LastReasoningEffort, + &i.Archived, + &i.LastError, + &i.Mode, + pq.Array(&i.MCPServerIDs), + &i.Labels, + &i.BuildID, + &i.AgentID, + &i.PinOrder, + &i.LastReadMessageID, + &i.DynamicTools, + &i.OrganizationID, + &i.PlanMode, + &i.ClientType, + &i.LastTurnSummary, + &i.Summary, + &i.SummaryGeneratedAt, + &i.SnapshotVersion, + &i.HistoryVersion, + &i.QueueVersion, + &i.GenerationAttempt, + &i.RetryState, + &i.RetryStateVersion, + &i.RunnerID, + &i.RequiresActionDeadlineAt, + &i.UserACL, + &i.GroupACL, + &i.OwnerUsername, + &i.OwnerName, + &i.ContextAggregateHash, + &i.ContextDirtySince, + &i.ContextDirtyResources, + &i.ContextError, + &i.CompactionRequestedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getActiveChatsByAgentID = `-- name: GetActiveChatsByAgentID :many 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, summary, summary_generated_at, 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, compaction_requested_at FROM chats_expanded @@ -11476,6 +11580,21 @@ func (q *sqlQuerier) SoftDeleteContextFileMessages(ctx context.Context, chatID u return err } +const startChatSummaryGeneration = `-- name: StartChatSummaryGeneration :one +INSERT INTO chat_summary_generations (chat_id) +VALUES ($1::uuid) +ON CONFLICT (chat_id) DO UPDATE +SET started_at = NOW() +RETURNING started_at +` + +func (q *sqlQuerier) StartChatSummaryGeneration(ctx context.Context, id uuid.UUID) (time.Time, error) { + row := q.db.QueryRowContext(ctx, startChatSummaryGeneration, id) + var started_at time.Time + err := row.Scan(&started_at) + return started_at, err +} + const unarchiveChatByID = `-- name: UnarchiveChatByID :many WITH updated_chats AS ( UPDATE chats SET @@ -13076,18 +13195,36 @@ SET WHERE id = $2::uuid AND history_version = $3::bigint + AND ( + $4::timestamptz IS NULL + OR EXISTS ( + SELECT 1 + FROM chat_summary_generations g + WHERE + g.chat_id = chats.id + AND g.started_at = $4::timestamptz + ) + ) ` type UpdateChatSummaryParams struct { - Summary sql.NullString `db:"summary" json:"summary"` - ID uuid.UUID `db:"id" json:"id"` - ExpectedHistoryVersion int64 `db:"expected_history_version" json:"expected_history_version"` + Summary sql.NullString `db:"summary" json:"summary"` + ID uuid.UUID `db:"id" json:"id"` + ExpectedHistoryVersion int64 `db:"expected_history_version" json:"expected_history_version"` + ExpectedGenerationStartedAt sql.NullTime `db:"expected_generation_started_at" json:"expected_generation_started_at"` } // The history_version fence lets background summary writes ignore worker-only -// updates while losing to newer message history. +// updates while losing to newer message history. Root summary workers also pass +// their generation marker so an older overlapping worker cannot overwrite a +// newer attempt. func (q *sqlQuerier) UpdateChatSummary(ctx context.Context, arg UpdateChatSummaryParams) (int64, error) { - result, err := q.db.ExecContext(ctx, updateChatSummary, arg.Summary, arg.ID, arg.ExpectedHistoryVersion) + result, err := q.db.ExecContext(ctx, updateChatSummary, + arg.Summary, + arg.ID, + arg.ExpectedHistoryVersion, + arg.ExpectedGenerationStartedAt, + ) if err != nil { return 0, err } diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index 7f6d667bea1f8..e47d45ffc234f 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -1474,16 +1474,51 @@ WHERE id = @id::uuid AND history_version = @expected_history_version::bigint; +-- name: StartChatSummaryGeneration :one +INSERT INTO chat_summary_generations (chat_id) +VALUES (@id::uuid) +ON CONFLICT (chat_id) DO UPDATE +SET started_at = NOW() +RETURNING started_at; + +-- name: ClearChatSummaryGeneration :exec +DELETE FROM chat_summary_generations +WHERE + chat_id = @id::uuid + AND started_at = sqlc.arg('generation_started_at')::timestamptz; + +-- name: GetActiveChatSummaryGenerationsByOwnerID :many +SELECT c.* +FROM chat_summary_generations g +JOIN chats_expanded c ON c.id = g.chat_id +WHERE + c.owner_id = @owner_id::uuid + AND c.parent_chat_id IS NULL + AND g.started_at > NOW() - (INTERVAL '1 second' * @max_age_seconds::int) +ORDER BY g.started_at; + -- name: UpdateChatSummary :execrows -- The history_version fence lets background summary writes ignore worker-only --- updates while losing to newer message history. +-- updates while losing to newer message history. Root summary workers also pass +-- their generation marker so an older overlapping worker cannot overwrite a +-- newer attempt. UPDATE chats SET summary = sqlc.narg('summary')::text, summary_generated_at = NOW() WHERE id = @id::uuid - AND history_version = @expected_history_version::bigint; + AND history_version = @expected_history_version::bigint + AND ( + sqlc.narg('expected_generation_started_at')::timestamptz IS NULL + OR EXISTS ( + SELECT 1 + FROM chat_summary_generations g + WHERE + g.chat_id = chats.id + AND g.started_at = sqlc.narg('expected_generation_started_at')::timestamptz + ) + ); -- name: UpdateChatMCPServerIDs :one WITH updated_chat AS ( diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 9d2390a6468ba..80adca6413313 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -35,6 +35,7 @@ const ( UniqueChatOrganizationModelOverridesOrganizationIDContextKey UniqueConstraint = "chat_organization_model_overrides_organization_id_context_key" // ALTER TABLE ONLY chat_organization_model_overrides ADD CONSTRAINT chat_organization_model_overrides_organization_id_context_key UNIQUE (organization_id, context); UniqueChatOrganizationModelOverridesPkey UniqueConstraint = "chat_organization_model_overrides_pkey" // ALTER TABLE ONLY chat_organization_model_overrides ADD CONSTRAINT chat_organization_model_overrides_pkey PRIMARY KEY (id); UniqueChatQueuedMessagesPkey UniqueConstraint = "chat_queued_messages_pkey" // ALTER TABLE ONLY chat_queued_messages ADD CONSTRAINT chat_queued_messages_pkey PRIMARY KEY (id); + UniqueChatSummaryGenerationsPkey UniqueConstraint = "chat_summary_generations_pkey" // ALTER TABLE ONLY chat_summary_generations ADD CONSTRAINT chat_summary_generations_pkey PRIMARY KEY (chat_id); UniqueChatUsageLimitConfigPkey UniqueConstraint = "chat_usage_limit_config_pkey" // ALTER TABLE ONLY chat_usage_limit_config ADD CONSTRAINT chat_usage_limit_config_pkey PRIMARY KEY (id); UniqueChatUsageLimitConfigSingletonKey UniqueConstraint = "chat_usage_limit_config_singleton_key" // ALTER TABLE ONLY chat_usage_limit_config ADD CONSTRAINT chat_usage_limit_config_singleton_key UNIQUE (singleton); UniqueChatUserModelOverridesPkey UniqueConstraint = "chat_user_model_overrides_pkey" // ALTER TABLE ONLY chat_user_model_overrides ADD CONSTRAINT chat_user_model_overrides_pkey PRIMARY KEY (id); diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 4cbfa58e116a9..9fc14280dc547 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -230,9 +230,8 @@ func (api *API) watchChats(rw http.ResponseWriter, r *http.Request) { if encoder == nil { return } - // The encoder is only written from the pubsub delivery - // goroutine, which processes messages serially. Do not - // add a second write path without synchronization. + // Replays finish before encoderReady closes. After that, + // only this serial pubsub delivery goroutine writes. if err := encoder.Encode(payload); err != nil { logger.Debug(cbCtx, "failed to send chat watch event", slog.Error(err)) cancel() @@ -251,6 +250,23 @@ func (api *API) watchChats(rw http.ResponseWriter, r *http.Request) { } defer cancelSubscribe() + activeSummaryGenerations, err := api.Database.GetActiveChatSummaryGenerationsByOwnerID( + ctx, + database.GetActiveChatSummaryGenerationsByOwnerIDParams{ + OwnerID: apiKey.UserID, + MaxAgeSeconds: int32(codersdk.ChatSummaryGenerationTimeout / time.Second), + }, + ) + if err != nil { + close(encoderReady) + logger.Error(ctx, "failed to load active chat summary generations", slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to load active chat summary generations.", + Detail: err.Error(), + }) + return + } + conn, err := websocket.Accept(rw, r, nil) if err != nil { close(encoderReady) @@ -269,6 +285,18 @@ func (api *API) watchChats(rw http.ResponseWriter, r *http.Request) { ctx = api.wsWatcher.Watch(ctx, logger, conn) encoder = json.NewEncoder(wsNetConn) + for _, chat := range activeSummaryGenerations { + if err := encoder.Encode(codersdk.ChatWatchEvent{ + Kind: codersdk.ChatWatchEventKindChatSummaryGenerating, + Chat: db2sdk.Chat(chat, nil, nil), + }); err != nil { + encoder = nil + close(encoderReady) + logger.Debug(ctx, "failed to replay chat summary generation", + slog.F("chat_id", chat.ID), slog.Error(err)) + return + } + } close(encoderReady) <-ctx.Done() diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 71cbda7ae4684..73ac7dcf47348 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -3157,6 +3157,38 @@ func TestWatchChats(t *testing.T) { } } }) + t.Run("ReplaysActiveSummaryGeneration", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + DeploymentValues: coderdtest.DeploymentValues(t), + }) + client := codersdk.NewExperimentalClient(rawClient) + user := coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModel(t, client) + chat := dbgen.Chat(t, api.Database, database.Chat{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + LastModelConfigID: modelConfig.ID, + }) + + _, err := api.Database.StartChatSummaryGeneration( + dbauthz.AsChatd(ctx), + chat.ID, + ) + require.NoError(t, err) + + conn, err := client.Dial(ctx, "/api/experimental/chats/watch", nil) + require.NoError(t, err) + defer conn.Close(websocket.StatusNormalClosure, "done") + + var payload codersdk.ChatWatchEvent + require.NoError(t, wsjson.Read(ctx, conn, &payload)) + require.Equal(t, codersdk.ChatWatchEventKindChatSummaryGenerating, payload.Kind) + require.Equal(t, chat.ID, payload.Chat.ID) + }) + t.Run("CreatedEventIncludesAllChatFields", func(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 3e9cc99e9b17b..1f3f9247c9369 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -4796,7 +4796,7 @@ const ( // New completed user turns before the summary is regenerated (since the last summary). summaryStaleTurnThreshold = 3 summaryMinTranscriptRunes = 200 - chatSummaryWorkTimeout = 120 * time.Second + chatSummaryWorkTimeout = codersdk.ChatSummaryGenerationTimeout chatSummaryGenerateTimeout = 60 * time.Second chatSummaryWriteTimeout = 5 * time.Second @@ -4889,6 +4889,14 @@ func (p *Server) generateAndStoreChatSummary( return } + generationStartedAt, err := p.db.StartChatSummaryGeneration(ctx, chat.ID) + if err != nil { + logger.Debug(ctx, "failed to mark chat summary generation", + slog.F("chat_id", chat.ID), slog.Error(err)) + return + } + defer p.clearChatSummaryGeneration(ctx, logger, chat.ID, generationStartedAt) + p.publishChatPubsubEvent(chat, codersdk.ChatWatchEventKindChatSummaryGenerating, nil) summaryCtx, cancelGen := context.WithTimeout(ctx, chatSummaryGenerateTimeout) @@ -4901,7 +4909,10 @@ func (p *Server) generateAndStoreChatSummary( return } - p.updateChatSummary(ctx, logger, chat, chat.HistoryVersion, summary) + p.updateChatSummary(ctx, logger, chat, chat.HistoryVersion, sql.NullTime{ + Time: generationStartedAt, + Valid: true, + }, summary) } func (p *Server) resolveChatSummaryModel( @@ -4955,6 +4966,24 @@ func countCompletedTurnsSince(messages []database.ChatMessage, after time.Time) return count } +func (p *Server) clearChatSummaryGeneration( + ctx context.Context, + logger slog.Logger, + chatID uuid.UUID, + generationStartedAt time.Time, +) { + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), chatSummaryWriteTimeout) + defer cancel() + + if err := p.db.ClearChatSummaryGeneration(ctx, database.ClearChatSummaryGenerationParams{ + ID: chatID, + GenerationStartedAt: generationStartedAt, + }); err != nil { + logger.Warn(ctx, "failed to clear chat summary generation", + slog.F("chat_id", chatID), slog.Error(err)) + } +} + // updateChatSummary persists the whole-chat summary. Best-effort background // write (pass a detached context); a blank summary is a no-op, never clearing // an existing one. @@ -4963,6 +4992,7 @@ func (p *Server) updateChatSummary( logger slog.Logger, chat database.Chat, expectedHistoryVersion int64, + expectedGenerationStartedAt sql.NullTime, summary string, ) { summary = strings.TrimSpace(summary) @@ -4975,9 +5005,10 @@ func (p *Server) updateChatSummary( defer cancel() affected, err := p.db.UpdateChatSummary(ctx, database.UpdateChatSummaryParams{ - ID: chat.ID, - ExpectedHistoryVersion: expectedHistoryVersion, - Summary: sqlSummary, + ID: chat.ID, + ExpectedHistoryVersion: expectedHistoryVersion, + ExpectedGenerationStartedAt: expectedGenerationStartedAt, + Summary: sqlSummary, }) if err != nil { logger.Warn(ctx, "failed to update chat summary", @@ -4993,6 +5024,10 @@ func (p *Server) updateChatSummary( return } + if expectedGenerationStartedAt.Valid { + p.clearChatSummaryGeneration(ctx, logger, chat.ID, expectedGenerationStartedAt.Time) + } + updatedChat := chat updatedChat.Summary = sqlSummary p.publishChatPubsubEvent(updatedChat, codersdk.ChatWatchEventKindChatSummaryChange, nil) @@ -5035,7 +5070,7 @@ func (p *Server) storeSubagentReportSummary( if summary == "" { return } - p.updateChatSummary(ctx, logger, chat, chat.HistoryVersion, summary) + p.updateChatSummary(ctx, logger, chat, chat.HistoryVersion, sql.NullTime{}, summary) } func (p *Server) webpushConfigured() bool { diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index d32455e8ee4e2..d5a619ba60592 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -117,7 +117,7 @@ func TestUpdateChatSummary(t *testing.T) { return 1, nil }) - server.updateChatSummary(ctx, logger, chat, chat.HistoryVersion, " \n trimmed summary\t ") + server.updateChatSummary(ctx, logger, chat, chat.HistoryVersion, sql.NullTime{}, " \n trimmed summary\t ") events := ps.watchEvents(t) require.Len(t, events, 1) @@ -135,7 +135,7 @@ func TestUpdateChatSummary(t *testing.T) { chat := database.Chat{ID: uuid.New(), OwnerID: uuid.New(), HistoryVersion: 7} logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) - server.updateChatSummary(context.Background(), logger, chat, chat.HistoryVersion, " \n\t ") + server.updateChatSummary(context.Background(), logger, chat, chat.HistoryVersion, sql.NullTime{}, " \n\t ") }) t.Run("SkipsEventOnStaleWrite", func(t *testing.T) { @@ -154,7 +154,7 @@ func TestUpdateChatSummary(t *testing.T) { Summary: sql.NullString{String: "stale summary", Valid: true}, }).Return(int64(0), nil) - server.updateChatSummary(context.Background(), logger, chat, chat.HistoryVersion, "stale summary") + server.updateChatSummary(context.Background(), logger, chat, chat.HistoryVersion, sql.NullTime{}, "stale summary") require.Empty(t, ps.watchEvents(t)) }) diff --git a/codersdk/chats.go b/codersdk/chats.go index f12d90ba4aa36..e63ee3e0f6d9d 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -1857,6 +1857,10 @@ func NewDynamicTool[T any]( } } +// ChatSummaryGenerationTimeout bounds how long an interrupted generation can +// be replayed to newly connected chat watchers. +const ChatSummaryGenerationTimeout = 2 * time.Minute + // ChatWatchEventKind represents the kind of event in the chat watch stream. type ChatWatchEventKind string diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx index a721a3e5a3933..9c7c652d5bc8f 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx @@ -1104,11 +1104,11 @@ export const SummaryWatchEventsUpdateOpenPanel: Story = { }; }, parameters: watchedChatPageParameters(watchedChat(), [ - chatWatchEvent("chat_summary_generating", watchedChat(), 500), + chatWatchEvent("chat_summary_generating", watchedChat(), 750), chatWatchEvent( "chat_summary_change", watchedChat({ summary: "Generated summary from the watch event." }), - 750, + 3_000, ), ]), play: async ({ canvasElement }) => { @@ -1121,14 +1121,20 @@ export const SummaryWatchEventsUpdateOpenPanel: Story = { await summary.findByText("Not enough details to summarize."), ).toBeVisible(); - const status = await summary.findByRole("status"); + const status = await summary.findByRole("status", undefined, { + timeout: 3_000, + }); expect(status).toHaveTextContent("Generating summary"); expect( summary.queryByText("Not enough details to summarize."), ).not.toBeInTheDocument(); expect( - await summary.findByText("Generated summary from the watch event."), + await summary.findByText( + "Generated summary from the watch event.", + {}, + { timeout: 5_000 }, + ), ).toBeVisible(); expect(summary.queryByRole("status")).not.toBeInTheDocument(); }, diff --git a/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx b/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx index d0bf6cf81c8b6..dc7859a067d37 100644 --- a/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx @@ -201,6 +201,15 @@ export const GeneratingSummary: Story = { }, }; +export const RegeneratingSummary: Story = { + args: { summary: "Existing summary remains visible.", isGenerating: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByRole("status")).toHaveTextContent("Generating summary"); + expect(canvas.getByText("Existing summary remains visible.")).toBeVisible(); + }, +}; + // A subagent's summary is its final report, persisted when it // completes, so an empty summary means the agent is still working. export const SubagentSummaryPending: Story = { diff --git a/site/src/pages/AgentsPage/components/ChatSummary.tsx b/site/src/pages/AgentsPage/components/ChatSummary.tsx index 0cbb08a225598..65a2d3fb6041d 100644 --- a/site/src/pages/AgentsPage/components/ChatSummary.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummary.tsx @@ -71,6 +71,12 @@ export const ChatSummary: FC = ({ /> )} + {isGenerating && trimmedSummary && ( + + Generating summary + + )} +
    {formatDateTime(createdAt, DATE_FORMAT.MEDIUM_DATE)} From 54f54afb40d43f26f491aafdf6d5b58eaefebd59 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 27 Aug 2026 17:10:37 +0000 Subject: [PATCH 15/27] fix: finish chat summary generation state --- coderd/apidoc/docs.go | 2 + coderd/apidoc/swagger.json | 2 + coderd/x/chatd/chatd.go | 30 +++++-- coderd/x/chatd/chatd_internal_test.go | 28 +++++++ codersdk/chats.go | 1 + docs/reference/api/schemas.md | 6 +- site/src/api/queries/chats.test.ts | 2 + site/src/api/typesGenerated.ts | 2 + .../AgentsPage/AgentsPageLayout.stories.tsx | 83 ++++++++++++++++++- .../pages/AgentsPage/AgentsPageLayout.test.ts | 6 ++ .../src/pages/AgentsPage/AgentsPageLayout.tsx | 24 ++++++ 11 files changed, 175 insertions(+), 11 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index ec2de95b05536..6cb1ff3be1acd 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -20069,6 +20069,7 @@ const docTemplate = `{ "summary_change", "chat_summary_change", "chat_summary_generating", + "chat_summary_failed", "title_change", "created", "deleted", @@ -20081,6 +20082,7 @@ const docTemplate = `{ "ChatWatchEventKindSummaryChange", "ChatWatchEventKindChatSummaryChange", "ChatWatchEventKindChatSummaryGenerating", + "ChatWatchEventKindChatSummaryFailed", "ChatWatchEventKindTitleChange", "ChatWatchEventKindCreated", "ChatWatchEventKindDeleted", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 155326a770613..80d91c08e9730 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -18176,6 +18176,7 @@ "summary_change", "chat_summary_change", "chat_summary_generating", + "chat_summary_failed", "title_change", "created", "deleted", @@ -18188,6 +18189,7 @@ "ChatWatchEventKindSummaryChange", "ChatWatchEventKindChatSummaryChange", "ChatWatchEventKindChatSummaryGenerating", + "ChatWatchEventKindChatSummaryFailed", "ChatWatchEventKindTitleChange", "ChatWatchEventKindCreated", "ChatWatchEventKindDeleted", diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 1f3f9247c9369..cc670e3a90b57 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -4895,7 +4895,12 @@ func (p *Server) generateAndStoreChatSummary( slog.F("chat_id", chat.ID), slog.Error(err)) return } - defer p.clearChatSummaryGeneration(ctx, logger, chat.ID, generationStartedAt) + summaryStored := false + defer func() { + if !summaryStored { + p.failChatSummaryGeneration(ctx, logger, chat, generationStartedAt) + } + }() p.publishChatPubsubEvent(chat, codersdk.ChatWatchEventKindChatSummaryGenerating, nil) @@ -4909,7 +4914,7 @@ func (p *Server) generateAndStoreChatSummary( return } - p.updateChatSummary(ctx, logger, chat, chat.HistoryVersion, sql.NullTime{ + summaryStored = p.updateChatSummary(ctx, logger, chat, chat.HistoryVersion, sql.NullTime{ Time: generationStartedAt, Valid: true, }, summary) @@ -4984,6 +4989,16 @@ func (p *Server) clearChatSummaryGeneration( } } +func (p *Server) failChatSummaryGeneration( + ctx context.Context, + logger slog.Logger, + chat database.Chat, + generationStartedAt time.Time, +) { + p.clearChatSummaryGeneration(ctx, logger, chat.ID, generationStartedAt) + p.publishChatPubsubEvent(chat, codersdk.ChatWatchEventKindChatSummaryFailed, nil) +} + // updateChatSummary persists the whole-chat summary. Best-effort background // write (pass a detached context); a blank summary is a no-op, never clearing // an existing one. @@ -4994,10 +5009,10 @@ func (p *Server) updateChatSummary( expectedHistoryVersion int64, expectedGenerationStartedAt sql.NullTime, summary string, -) { +) bool { summary = strings.TrimSpace(summary) if summary == "" { - return + return false } sqlSummary := sql.NullString{String: summary, Valid: true} @@ -5013,7 +5028,7 @@ func (p *Server) updateChatSummary( if err != nil { logger.Warn(ctx, "failed to update chat summary", slog.F("chat_id", chat.ID), slog.Error(err)) - return + return false } if affected == 0 { logger.Info(ctx, "skipped stale chat summary update", @@ -5021,7 +5036,7 @@ func (p *Server) updateChatSummary( slog.F("summary_length", len(summary)), slog.F("expected_history_version", expectedHistoryVersion), ) - return + return false } if expectedGenerationStartedAt.Valid { @@ -5031,6 +5046,7 @@ func (p *Server) updateChatSummary( updatedChat := chat updatedChat.Summary = sqlSummary p.publishChatPubsubEvent(updatedChat, codersdk.ChatWatchEventKindChatSummaryChange, nil) + return true } func (p *Server) storeSubagentReportSummaryAsync( @@ -5070,7 +5086,7 @@ func (p *Server) storeSubagentReportSummary( if summary == "" { return } - p.updateChatSummary(ctx, logger, chat, chat.HistoryVersion, sql.NullTime{}, summary) + _ = p.updateChatSummary(ctx, logger, chat, chat.HistoryVersion, sql.NullTime{}, summary) } func (p *Server) webpushConfigured() bool { diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index d5a619ba60592..9e593deaf4f19 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -86,6 +86,34 @@ func (t *testMCPAgentTool) MCPServerConfigID() uuid.UUID { return t.configID } +func TestFailChatSummaryGeneration(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + ps := newRecordingPubsub(dbpubsub.NewInMemory()) + server := &Server{db: db, pubsub: ps} + chat := database.Chat{ID: uuid.New(), OwnerID: uuid.New()} + generationStartedAt := time.Now() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + + db.EXPECT().ClearChatSummaryGeneration(gomock.Any(), database.ClearChatSummaryGenerationParams{ + ID: chat.ID, + GenerationStartedAt: generationStartedAt, + }).Return(nil) + + server.failChatSummaryGeneration( + context.Background(), + logger, + chat, + generationStartedAt, + ) + + events := ps.watchEvents(t) + require.Len(t, events, 1) + require.Equal(t, codersdk.ChatWatchEventKindChatSummaryFailed, events[0].Kind) +} + func TestUpdateChatSummary(t *testing.T) { t.Parallel() diff --git a/codersdk/chats.go b/codersdk/chats.go index e63ee3e0f6d9d..6d76945dc93df 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -1872,6 +1872,7 @@ const ( // the frontend updates one field without disturbing the other. ChatWatchEventKindChatSummaryChange ChatWatchEventKind = "chat_summary_change" ChatWatchEventKindChatSummaryGenerating ChatWatchEventKind = "chat_summary_generating" + ChatWatchEventKindChatSummaryFailed ChatWatchEventKind = "chat_summary_failed" ChatWatchEventKindTitleChange ChatWatchEventKind = "title_change" ChatWatchEventKindCreated ChatWatchEventKind = "created" ChatWatchEventKindDeleted ChatWatchEventKind = "deleted" diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index a8cbe595cace4..59c0d8502527c 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -5341,9 +5341,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in #### Enumerated Values -| Value(s) | -|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action_required`, `chat_summary_change`, `chat_summary_generating`, `context_dirty`, `created`, `deleted`, `diff_status_change`, `status_change`, `summary_change`, `title_change` | +| Value(s) | +|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action_required`, `chat_summary_change`, `chat_summary_failed`, `chat_summary_generating`, `context_dirty`, `created`, `deleted`, `diff_status_change`, `status_change`, `summary_change`, `title_change` | ## codersdk.ClusterConfig diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index 701d55eab4169..1d9e98426f451 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -3464,6 +3464,7 @@ describe("semantic cache operations: prefix invalidations", () => { const expectedByKind: Record = { action_required: true, chat_summary_change: false, + chat_summary_failed: false, chat_summary_generating: false, context_dirty: false, created: false, @@ -3544,6 +3545,7 @@ describe("semantic cache operations: prefix invalidations", () => { const expectedByKind: Record = { action_required: true, chat_summary_change: false, + chat_summary_failed: false, chat_summary_generating: false, context_dirty: false, created: false, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index c3258732d24c6..c7c0c15b11665 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3629,6 +3629,7 @@ export interface ChatWatchEvent { export type ChatWatchEventKind = | "action_required" | "chat_summary_change" + | "chat_summary_failed" | "chat_summary_generating" | "context_dirty" | "created" @@ -3641,6 +3642,7 @@ export type ChatWatchEventKind = export const ChatWatchEventKinds: ChatWatchEventKind[] = [ "action_required", "chat_summary_change", + "chat_summary_failed", "chat_summary_generating", "context_dirty", "created", diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx index 9c7c652d5bc8f..165c0a14985e3 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx @@ -1069,7 +1069,10 @@ const chatWatchEvent = ( const watchedChatPageParameters = ( chat: Chat, - watchEvents: readonly ReturnType[], + watchEvents: readonly ( + | ReturnType + | { event: "open"; delayMs?: number } + )[], ) => ({ queries: watchedChatQueries(chat), webSocket: { @@ -1140,6 +1143,84 @@ export const SummaryWatchEventsUpdateOpenPanel: Story = { }, }; +export const SummaryReconnectClearsStaleGeneratingState: Story = { + decorators: [withProxyProvider()], + beforeEach: () => { + mockChats([watchedChat()]); + const cleanup = mockAgentChatPageAPIs(); + clearPersistedSidebarTabId(WATCHED_CHAT_ID); + localStorage.setItem(RIGHT_PANEL_OPEN_KEY, "true"); + return () => { + clearPersistedSidebarTabId(WATCHED_CHAT_ID); + cleanup(); + }; + }, + parameters: watchedChatPageParameters(watchedChat(), [ + chatWatchEvent("chat_summary_generating", watchedChat(), 750), + { event: "open", delayMs: 3_000 }, + ]), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const summaryPanel = await canvas.findByRole("tabpanel", { + name: "Summary", + }); + const summary = within(summaryPanel); + expect( + await summary.findByText("Not enough details to summarize."), + ).toBeVisible(); + expect( + await summary.findByRole("status", undefined, { timeout: 3_000 }), + ).toHaveTextContent("Generating summary"); + expect( + await summary.findByText( + "Not enough details to summarize.", + {}, + { timeout: 5_000 }, + ), + ).toBeVisible(); + expect(summary.queryByRole("status")).not.toBeInTheDocument(); + }, +}; + +export const SummaryFailureClearsGeneratingState: Story = { + decorators: [withProxyProvider()], + beforeEach: () => { + mockChats([watchedChat()]); + const cleanup = mockAgentChatPageAPIs(); + clearPersistedSidebarTabId(WATCHED_CHAT_ID); + localStorage.setItem(RIGHT_PANEL_OPEN_KEY, "true"); + return () => { + clearPersistedSidebarTabId(WATCHED_CHAT_ID); + cleanup(); + }; + }, + parameters: watchedChatPageParameters(watchedChat(), [ + chatWatchEvent("chat_summary_generating", watchedChat(), 750), + chatWatchEvent("chat_summary_failed", watchedChat(), 3_000), + ]), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const summaryPanel = await canvas.findByRole("tabpanel", { + name: "Summary", + }); + const summary = within(summaryPanel); + expect( + await summary.findByText("Not enough details to summarize."), + ).toBeVisible(); + expect( + await summary.findByRole("status", undefined, { timeout: 3_000 }), + ).toHaveTextContent("Generating summary"); + expect( + await summary.findByText( + "Not enough details to summarize.", + {}, + { timeout: 5_000 }, + ), + ).toBeVisible(); + expect(summary.queryByRole("status")).not.toBeInTheDocument(); + }, +}; + export const ArchiveWatchEventKeepsOpenChatMounted: Story = { decorators: [withProxyProvider()], beforeEach: () => { diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.test.ts b/site/src/pages/AgentsPage/AgentsPageLayout.test.ts index ea672c68f801e..65c02a26c7bf8 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.test.ts +++ b/site/src/pages/AgentsPage/AgentsPageLayout.test.ts @@ -1039,6 +1039,12 @@ describe(chatCostIdToInvalidate.name, () => { eventKind: "chat_summary_change", expected: "chat-1", }, + { + name: "invalidates when whole-chat summary generation fails", + updatedChat: chatForFilterInvalidation({ status: "waiting" }), + eventKind: "chat_summary_failed", + expected: "chat-1", + }, { name: "invalidates the root's tree cost for a subagent summary change", updatedChat: chatForFilterInvalidation({ diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.tsx index 9ec027d42ae77..2634a353f6252 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.tsx @@ -138,6 +138,7 @@ export const shouldInvalidateFilteredChatList = ( // status, so invalidate the root-keyed cost query when those events arrive. const POST_TURN_BILLED_EVENT_KINDS = new Set([ "chat_summary_change", + "chat_summary_failed", "summary_change", "title_change", ]); @@ -577,6 +578,15 @@ const AgentsPageLayout: FC = () => { return next; }); }; + const clearAllSummaryGenerating = () => { + for (const timeout of summaryGeneratingTimeouts.values()) { + clearTimeout(timeout); + } + summaryGeneratingTimeouts.clear(); + setSummaryGeneratingChatIds((current) => + current.size === 0 ? current : new Set(), + ); + }; const markSummaryGenerating = (chatId: string) => { const previousTimeout = summaryGeneratingTimeouts.get(chatId); if (previousTimeout !== undefined) { @@ -610,6 +620,7 @@ const AgentsPageLayout: FC = () => { markSummaryGenerating(updatedChat.id); } else if ( chatEvent.kind === "chat_summary_change" || + chatEvent.kind === "chat_summary_failed" || chatEvent.kind === "deleted" ) { const timeout = summaryGeneratingTimeouts.get(updatedChat.id); @@ -725,6 +736,19 @@ const AgentsPageLayout: FC = () => { return ws; }, onOpen() { + clearAllSummaryGenerating(); + const activeChatId = activeChatIDRef.current; + if (activeChatId) { + const activeChat = queryClient.getQueryData( + chatEntityKey(activeChatId), + ); + if (activeChat) { + const costChatId = getChatCostTreeID(activeChat); + if (costChatId) { + void invalidateChatCostTree(queryClient, costChatId); + } + } + } void invalidateChatListQueries(queryClient); void invalidateChatsByWorkspace(queryClient); void invalidateChatSearches(queryClient); From 06f70a83fa99925f4e89c3450d131d298880b958 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 27 Aug 2026 17:24:05 +0000 Subject: [PATCH 16/27] fix: ignore superseded summary failures --- coderd/database/dbauthz/dbauthz.go | 6 ++--- coderd/database/dbauthz/dbauthz_test.go | 4 ++-- coderd/database/dbmetrics/querymetrics.go | 6 ++--- coderd/database/dbmock/dbmock.go | 7 +++--- coderd/database/querier.go | 2 +- coderd/database/querier_test.go | 3 ++- coderd/database/queries.sql.go | 11 +++++---- coderd/database/queries/chats.sql | 2 +- coderd/x/chatd/chatd.go | 16 ++++++++----- coderd/x/chatd/chatd_internal_test.go | 28 ++++++++++++++++++++++- 10 files changed, 60 insertions(+), 25 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 41393efbc297c..7d466c54a2c14 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1988,13 +1988,13 @@ func (q *querier) CleanupDeletedMCPServerIDsFromChats(ctx context.Context) error return q.db.CleanupDeletedMCPServerIDsFromChats(ctx) } -func (q *querier) ClearChatSummaryGeneration(ctx context.Context, arg database.ClearChatSummaryGenerationParams) error { +func (q *querier) ClearChatSummaryGeneration(ctx context.Context, arg database.ClearChatSummaryGenerationParams) (int64, error) { chat, err := q.db.GetChatByID(ctx, arg.ID) if err != nil { - return err + return 0, err } if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { - return err + return 0, err } return q.db.ClearChatSummaryGeneration(ctx, arg) } diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index b767fe9df89ae..4a9e2b51bcd79 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1973,8 +1973,8 @@ func (s *MethodTestSuite) TestChats() { GenerationStartedAt: time.Now(), } dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() - dbm.EXPECT().ClearChatSummaryGeneration(gomock.Any(), arg).Return(nil).AnyTimes() - check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns() + dbm.EXPECT().ClearChatSummaryGeneration(gomock.Any(), arg).Return(int64(1), nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(int64(1)) })) s.Run("UpdateChatSummary", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 4465029478787..76da6406af372 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -312,12 +312,12 @@ func (m queryMetricsStore) CleanupDeletedMCPServerIDsFromChats(ctx context.Conte return r0 } -func (m queryMetricsStore) ClearChatSummaryGeneration(ctx context.Context, arg database.ClearChatSummaryGenerationParams) error { +func (m queryMetricsStore) ClearChatSummaryGeneration(ctx context.Context, arg database.ClearChatSummaryGenerationParams) (int64, error) { start := time.Now() - r0 := m.s.ClearChatSummaryGeneration(ctx, arg) + r0, r1 := m.s.ClearChatSummaryGeneration(ctx, arg) m.queryLatencies.WithLabelValues("ClearChatSummaryGeneration").Observe(time.Since(start).Seconds()) m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ClearChatSummaryGeneration").Inc() - return r0 + return r0, r1 } func (m queryMetricsStore) CountAIBridgeSessions(ctx context.Context, arg database.CountAIBridgeSessionsParams) (int64, error) { diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index bd5129f7e3382..e7670c45284ed 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -423,11 +423,12 @@ func (mr *MockStoreMockRecorder) CleanupDeletedMCPServerIDsFromChats(ctx any) *g } // ClearChatSummaryGeneration mocks base method. -func (m *MockStore) ClearChatSummaryGeneration(ctx context.Context, arg database.ClearChatSummaryGenerationParams) error { +func (m *MockStore) ClearChatSummaryGeneration(ctx context.Context, arg database.ClearChatSummaryGenerationParams) (int64, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ClearChatSummaryGeneration", ctx, arg) - ret0, _ := ret[0].(error) - return ret0 + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 } // ClearChatSummaryGeneration indicates an expected call of ClearChatSummaryGeneration. diff --git a/coderd/database/querier.go b/coderd/database/querier.go index a68435f4c14d6..e01153537bc39 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -93,7 +93,7 @@ type sqlcQuerier interface { CleanTailnetLostPeers(ctx context.Context) error CleanTailnetTunnels(ctx context.Context) error CleanupDeletedMCPServerIDsFromChats(ctx context.Context) error - ClearChatSummaryGeneration(ctx context.Context, arg ClearChatSummaryGenerationParams) error + ClearChatSummaryGeneration(ctx context.Context, arg ClearChatSummaryGenerationParams) (int64, error) CountAIBridgeSessions(ctx context.Context, arg CountAIBridgeSessionsParams) (int64, error) CountAuditLogs(ctx context.Context, arg CountAuditLogsParams) (int64, error) // Excluding the candidate keeps ownership takeover capacity-neutral. diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 159a171c6f3bb..fde6a6f516772 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -16217,11 +16217,12 @@ func TestUpdateChatSummary(t *testing.T) { require.True(t, fetched.SummaryGeneratedAt.Valid) require.Equal(t, chat.UpdatedAt, fetched.UpdatedAt) - err = db.ClearChatSummaryGeneration(ctx, database.ClearChatSummaryGenerationParams{ + cleared, err := db.ClearChatSummaryGeneration(ctx, database.ClearChatSummaryGenerationParams{ ID: chat.ID, GenerationStartedAt: generationStartedAt, }) require.NoError(t, err) + require.EqualValues(t, 1, cleared) activeGenerations, err = db.GetActiveChatSummaryGenerationsByOwnerID(ctx, database.GetActiveChatSummaryGenerationsByOwnerIDParams{ OwnerID: owner.ID, diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 1ace21dc22788..f6a093fd70b91 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -7492,7 +7492,7 @@ func (q *sqlQuerier) BatchUpsertChatHeartbeats(ctx context.Context, arg BatchUps return err } -const clearChatSummaryGeneration = `-- name: ClearChatSummaryGeneration :exec +const clearChatSummaryGeneration = `-- name: ClearChatSummaryGeneration :execrows DELETE FROM chat_summary_generations WHERE chat_id = $1::uuid @@ -7504,9 +7504,12 @@ type ClearChatSummaryGenerationParams struct { GenerationStartedAt time.Time `db:"generation_started_at" json:"generation_started_at"` } -func (q *sqlQuerier) ClearChatSummaryGeneration(ctx context.Context, arg ClearChatSummaryGenerationParams) error { - _, err := q.db.ExecContext(ctx, clearChatSummaryGeneration, arg.ID, arg.GenerationStartedAt) - return err +func (q *sqlQuerier) ClearChatSummaryGeneration(ctx context.Context, arg ClearChatSummaryGenerationParams) (int64, error) { + result, err := q.db.ExecContext(ctx, clearChatSummaryGeneration, arg.ID, arg.GenerationStartedAt) + if err != nil { + return 0, err + } + return result.RowsAffected() } const countChatCapacityActiveByPool = `-- name: CountChatCapacityActiveByPool :one diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index e47d45ffc234f..57993bce1efe6 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -1481,7 +1481,7 @@ ON CONFLICT (chat_id) DO UPDATE SET started_at = NOW() RETURNING started_at; --- name: ClearChatSummaryGeneration :exec +-- name: ClearChatSummaryGeneration :execrows DELETE FROM chat_summary_generations WHERE chat_id = @id::uuid diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index cc670e3a90b57..81d8172232f86 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -4976,17 +4976,20 @@ func (p *Server) clearChatSummaryGeneration( logger slog.Logger, chatID uuid.UUID, generationStartedAt time.Time, -) { +) bool { ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), chatSummaryWriteTimeout) defer cancel() - if err := p.db.ClearChatSummaryGeneration(ctx, database.ClearChatSummaryGenerationParams{ + affected, err := p.db.ClearChatSummaryGeneration(ctx, database.ClearChatSummaryGenerationParams{ ID: chatID, GenerationStartedAt: generationStartedAt, - }); err != nil { + }) + if err != nil { logger.Warn(ctx, "failed to clear chat summary generation", slog.F("chat_id", chatID), slog.Error(err)) + return false } + return affected > 0 } func (p *Server) failChatSummaryGeneration( @@ -4995,8 +4998,9 @@ func (p *Server) failChatSummaryGeneration( chat database.Chat, generationStartedAt time.Time, ) { - p.clearChatSummaryGeneration(ctx, logger, chat.ID, generationStartedAt) - p.publishChatPubsubEvent(chat, codersdk.ChatWatchEventKindChatSummaryFailed, nil) + if p.clearChatSummaryGeneration(ctx, logger, chat.ID, generationStartedAt) { + p.publishChatPubsubEvent(chat, codersdk.ChatWatchEventKindChatSummaryFailed, nil) + } } // updateChatSummary persists the whole-chat summary. Best-effort background @@ -5040,7 +5044,7 @@ func (p *Server) updateChatSummary( } if expectedGenerationStartedAt.Valid { - p.clearChatSummaryGeneration(ctx, logger, chat.ID, expectedGenerationStartedAt.Time) + _ = p.clearChatSummaryGeneration(ctx, logger, chat.ID, expectedGenerationStartedAt.Time) } updatedChat := chat diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index 9e593deaf4f19..a7009a1d58d92 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -100,7 +100,7 @@ func TestFailChatSummaryGeneration(t *testing.T) { db.EXPECT().ClearChatSummaryGeneration(gomock.Any(), database.ClearChatSummaryGenerationParams{ ID: chat.ID, GenerationStartedAt: generationStartedAt, - }).Return(nil) + }).Return(int64(1), nil) server.failChatSummaryGeneration( context.Background(), @@ -114,6 +114,32 @@ func TestFailChatSummaryGeneration(t *testing.T) { require.Equal(t, codersdk.ChatWatchEventKindChatSummaryFailed, events[0].Kind) } +func TestFailChatSummaryGenerationSuperseded(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + ps := newRecordingPubsub(dbpubsub.NewInMemory()) + server := &Server{db: db, pubsub: ps} + chat := database.Chat{ID: uuid.New(), OwnerID: uuid.New()} + generationStartedAt := time.Now() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + + db.EXPECT().ClearChatSummaryGeneration(gomock.Any(), database.ClearChatSummaryGenerationParams{ + ID: chat.ID, + GenerationStartedAt: generationStartedAt, + }).Return(int64(0), nil) + + server.failChatSummaryGeneration( + context.Background(), + logger, + chat, + generationStartedAt, + ) + + require.Empty(t, ps.watchEvents(t)) +} + func TestUpdateChatSummary(t *testing.T) { t.Parallel() From f6c41cac572af59fb81ac407f15fc38be292412b Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 27 Aug 2026 17:36:39 +0000 Subject: [PATCH 17/27] fix(coderd/database): add summary generation migration --- .../database/migrations/000551_chat_summary.down.sql | 2 -- coderd/database/migrations/000551_chat_summary.up.sql | 7 ------- .../000585_chat_summary_generation_state.down.sql | 1 + .../000585_chat_summary_generation_state.up.sql | 6 ++++++ .../000585_chat_summary_generation_state.up.sql | 10 ++++++++++ 5 files changed, 17 insertions(+), 9 deletions(-) create mode 100644 coderd/database/migrations/000585_chat_summary_generation_state.down.sql create mode 100644 coderd/database/migrations/000585_chat_summary_generation_state.up.sql create mode 100644 coderd/database/migrations/testdata/fixtures/000585_chat_summary_generation_state.up.sql diff --git a/coderd/database/migrations/000551_chat_summary.down.sql b/coderd/database/migrations/000551_chat_summary.down.sql index f64e3c0622604..4b56bc9b93af8 100644 --- a/coderd/database/migrations/000551_chat_summary.down.sql +++ b/coderd/database/migrations/000551_chat_summary.down.sql @@ -2,8 +2,6 @@ -- the summary columns, matching the 000549 chats_expanded definition. DROP VIEW IF EXISTS chats_expanded; -DROP TABLE chat_summary_generations; - ALTER TABLE chats DROP COLUMN summary, DROP COLUMN summary_generated_at; diff --git a/coderd/database/migrations/000551_chat_summary.up.sql b/coderd/database/migrations/000551_chat_summary.up.sql index 21f609c47d40f..3b3e8a407ff27 100644 --- a/coderd/database/migrations/000551_chat_summary.up.sql +++ b/coderd/database/migrations/000551_chat_summary.up.sql @@ -4,13 +4,6 @@ ALTER TABLE chats ADD COLUMN summary TEXT, ADD COLUMN summary_generated_at TIMESTAMPTZ; --- Active summary generation is tracked separately so chat reads stay stable --- while late watch subscribers can recover the transient loading state. -CREATE TABLE chat_summary_generations ( - chat_id UUID PRIMARY KEY REFERENCES chats(id) ON DELETE CASCADE, - started_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -- Recreate chats_expanded: its explicit column list hides new columns otherwise. DROP VIEW IF EXISTS chats_expanded; diff --git a/coderd/database/migrations/000585_chat_summary_generation_state.down.sql b/coderd/database/migrations/000585_chat_summary_generation_state.down.sql new file mode 100644 index 0000000000000..3d51ffbed2dc5 --- /dev/null +++ b/coderd/database/migrations/000585_chat_summary_generation_state.down.sql @@ -0,0 +1 @@ +DROP TABLE chat_summary_generations; diff --git a/coderd/database/migrations/000585_chat_summary_generation_state.up.sql b/coderd/database/migrations/000585_chat_summary_generation_state.up.sql new file mode 100644 index 0000000000000..ef6895ebae887 --- /dev/null +++ b/coderd/database/migrations/000585_chat_summary_generation_state.up.sql @@ -0,0 +1,6 @@ +-- Active summary generation is tracked separately so chat reads stay stable +-- while late watch subscribers can recover the transient loading state. +CREATE TABLE chat_summary_generations ( + chat_id UUID PRIMARY KEY REFERENCES chats(id) ON DELETE CASCADE, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/coderd/database/migrations/testdata/fixtures/000585_chat_summary_generation_state.up.sql b/coderd/database/migrations/testdata/fixtures/000585_chat_summary_generation_state.up.sql new file mode 100644 index 0000000000000..1d4fd2959e43a --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000585_chat_summary_generation_state.up.sql @@ -0,0 +1,10 @@ +INSERT INTO chat_summary_generations ( + chat_id, + started_at +) +SELECT + chats.id, + '2024-01-01 00:00:00+00' +FROM chats +ORDER BY created_at, id +LIMIT 1; From f113a513baca4b8bd44f059f0654ffe916e9ec76 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 27 Aug 2026 17:52:50 +0000 Subject: [PATCH 18/27] fix: preserve summary replay timeout --- coderd/apidoc/docs.go | 4 + coderd/apidoc/swagger.json | 4 + coderd/database/dbauthz/dbauthz.go | 13 +- coderd/database/dbauthz/dbauthz_test.go | 8 +- coderd/database/dbmetrics/querymetrics.go | 2 +- coderd/database/dbmock/dbmock.go | 4 +- coderd/database/querier.go | 2 +- coderd/database/querier_test.go | 4 +- coderd/database/queries.sql.go | 121 ++++++++++-------- coderd/database/queries/chats.sql | 15 ++- coderd/exp_chats.go | 10 +- coderd/exp_chats_test.go | 7 + codersdk/chats.go | 9 +- docs/reference/api/chats.md | 1 + docs/reference/api/schemas.md | 12 +- site/src/api/typesGenerated.ts | 5 + .../AgentsPage/AgentsPageLayout.stories.tsx | 45 ++++++- .../src/pages/AgentsPage/AgentsPageLayout.tsx | 21 ++- 18 files changed, 208 insertions(+), 79 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 6cb1ff3be1acd..c4fb8ec796cb3 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -20051,6 +20051,10 @@ const docTemplate = `{ "chat": { "$ref": "#/definitions/codersdk.Chat" }, + "chat_summary_generation_remaining_ms": { + "description": "ChatSummaryGenerationRemainingMS is present on chat_summary_generating\nevents so clients do not restart the generation timeout after reconnecting.", + "type": "integer" + }, "kind": { "$ref": "#/definitions/codersdk.ChatWatchEventKind" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 80d91c08e9730..f74a1d8021a94 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -18158,6 +18158,10 @@ "chat": { "$ref": "#/definitions/codersdk.Chat" }, + "chat_summary_generation_remaining_ms": { + "description": "ChatSummaryGenerationRemainingMS is present on chat_summary_generating\nevents so clients do not restart the generation timeout after reconnecting.", + "type": "integer" + }, "kind": { "$ref": "#/definitions/codersdk.ChatWatchEventKind" }, diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 7d466c54a2c14..3621cc5e33d73 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3035,8 +3035,17 @@ func (q *querier) GetActiveAISeatCount(ctx context.Context) (int64, error) { return q.db.GetActiveAISeatCount(ctx) } -func (q *querier) GetActiveChatSummaryGenerationsByOwnerID(ctx context.Context, arg database.GetActiveChatSummaryGenerationsByOwnerIDParams) ([]database.Chat, error) { - return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetActiveChatSummaryGenerationsByOwnerID)(ctx, arg) +func (q *querier) GetActiveChatSummaryGenerationsByOwnerID(ctx context.Context, arg database.GetActiveChatSummaryGenerationsByOwnerIDParams) ([]database.GetActiveChatSummaryGenerationsByOwnerIDRow, error) { + rows, err := q.db.GetActiveChatSummaryGenerationsByOwnerID(ctx, arg) + if err != nil { + return nil, err + } + for _, row := range rows { + if err := q.authorizeContext(ctx, policy.ActionRead, row.Chat); err != nil { + return nil, err + } + } + return rows, nil } func (q *querier) GetActiveChatsByAgentID(ctx context.Context, agentID uuid.UUID) ([]database.Chat, error) { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 4a9e2b51bcd79..a9086466153d2 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -998,8 +998,12 @@ func (s *MethodTestSuite) TestChats() { OwnerID: chat.OwnerID, MaxAgeSeconds: 120, } - dbm.EXPECT().GetActiveChatSummaryGenerationsByOwnerID(gomock.Any(), arg).Return([]database.Chat{chat}, nil).AnyTimes() - check.Args(arg).Asserts(chat, policy.ActionRead).Returns([]database.Chat{chat}) + rows := []database.GetActiveChatSummaryGenerationsByOwnerIDRow{{ + Chat: chat, + RemainingMs: 60_000, + }} + dbm.EXPECT().GetActiveChatSummaryGenerationsByOwnerID(gomock.Any(), arg).Return(rows, nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionRead).Returns(rows) })) s.Run("SoftDeleteContextFileMessages", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 76da6406af372..109733d41795d 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1296,7 +1296,7 @@ func (m queryMetricsStore) GetActiveAISeatCount(ctx context.Context) (int64, err return r0, r1 } -func (m queryMetricsStore) GetActiveChatSummaryGenerationsByOwnerID(ctx context.Context, arg database.GetActiveChatSummaryGenerationsByOwnerIDParams) ([]database.Chat, error) { +func (m queryMetricsStore) GetActiveChatSummaryGenerationsByOwnerID(ctx context.Context, arg database.GetActiveChatSummaryGenerationsByOwnerIDParams) ([]database.GetActiveChatSummaryGenerationsByOwnerIDRow, error) { start := time.Now() r0, r1 := m.s.GetActiveChatSummaryGenerationsByOwnerID(ctx, arg) m.queryLatencies.WithLabelValues("GetActiveChatSummaryGenerationsByOwnerID").Observe(time.Since(start).Seconds()) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index e7670c45284ed..ed11be9f56485 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2280,10 +2280,10 @@ func (mr *MockStoreMockRecorder) GetActiveAISeatCount(ctx any) *gomock.Call { } // GetActiveChatSummaryGenerationsByOwnerID mocks base method. -func (m *MockStore) GetActiveChatSummaryGenerationsByOwnerID(ctx context.Context, arg database.GetActiveChatSummaryGenerationsByOwnerIDParams) ([]database.Chat, error) { +func (m *MockStore) GetActiveChatSummaryGenerationsByOwnerID(ctx context.Context, arg database.GetActiveChatSummaryGenerationsByOwnerIDParams) ([]database.GetActiveChatSummaryGenerationsByOwnerIDRow, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetActiveChatSummaryGenerationsByOwnerID", ctx, arg) - ret0, _ := ret[0].([]database.Chat) + ret0, _ := ret[0].([]database.GetActiveChatSummaryGenerationsByOwnerIDRow) ret1, _ := ret[1].(error) return ret0, ret1 } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index e01153537bc39..104f9ab4a1eae 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -367,7 +367,7 @@ type sqlcQuerier interface { GetAPIKeysByUserID(ctx context.Context, arg GetAPIKeysByUserIDParams) ([]APIKey, error) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error) GetActiveAISeatCount(ctx context.Context) (int64, error) - GetActiveChatSummaryGenerationsByOwnerID(ctx context.Context, arg GetActiveChatSummaryGenerationsByOwnerIDParams) ([]Chat, error) + GetActiveChatSummaryGenerationsByOwnerID(ctx context.Context, arg GetActiveChatSummaryGenerationsByOwnerIDParams) ([]GetActiveChatSummaryGenerationsByOwnerIDRow, error) GetActiveChatsByAgentID(ctx context.Context, agentID uuid.UUID) ([]Chat, error) GetActivePresetPrebuildSchedules(ctx context.Context) ([]TemplateVersionPresetPrebuildSchedule, error) GetActiveUserCount(ctx context.Context, includeSystem bool) (int64, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index fde6a6f516772..b54d1fe7e43b1 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -16184,7 +16184,9 @@ func TestUpdateChatSummary(t *testing.T) { }) require.NoError(t, err) require.Len(t, activeGenerations, 1) - require.Equal(t, chat.ID, activeGenerations[0].ID) + require.Equal(t, chat.ID, activeGenerations[0].Chat.ID) + require.Positive(t, activeGenerations[0].RemainingMs) + require.LessOrEqual(t, activeGenerations[0].RemainingMs, int64(60_000)) expiredGenerations, err := db.GetActiveChatSummaryGenerationsByOwnerID(ctx, database.GetActiveChatSummaryGenerationsByOwnerIDParams{ OwnerID: owner.ID, diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index f6a093fd70b91..de4de87563625 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -7717,13 +7717,24 @@ func (q *sqlQuerier) DeleteStaleChatHeartbeats(ctx context.Context, staleSeconds } const getActiveChatSummaryGenerationsByOwnerID = `-- name: GetActiveChatSummaryGenerationsByOwnerID :many -SELECT c.id, c.owner_id, c.workspace_id, c.title, c.status, c.worker_id, c.started_at, c.heartbeat_at, c.created_at, c.updated_at, c.parent_chat_id, c.root_chat_id, c.last_model_config_id, c.last_reasoning_effort, c.archived, c.last_error, c.mode, c.mcp_server_ids, c.labels, c.build_id, c.agent_id, c.pin_order, c.last_read_message_id, c.dynamic_tools, c.organization_id, c.plan_mode, c.client_type, c.last_turn_summary, c.summary, c.summary_generated_at, c.snapshot_version, c.history_version, c.queue_version, c.generation_attempt, c.retry_state, c.retry_state_version, c.runner_id, c.requires_action_deadline_at, c.user_acl, c.group_acl, c.owner_username, c.owner_name, c.context_aggregate_hash, c.context_dirty_since, c.context_dirty_resources, c.context_error, c.compaction_requested_at +WITH params AS ( + SELECT $2::int AS max_age_seconds +) +SELECT + c.id, c.owner_id, c.workspace_id, c.title, c.status, c.worker_id, c.started_at, c.heartbeat_at, c.created_at, c.updated_at, c.parent_chat_id, c.root_chat_id, c.last_model_config_id, c.last_reasoning_effort, c.archived, c.last_error, c.mode, c.mcp_server_ids, c.labels, c.build_id, c.agent_id, c.pin_order, c.last_read_message_id, c.dynamic_tools, c.organization_id, c.plan_mode, c.client_type, c.last_turn_summary, c.summary, c.summary_generated_at, c.snapshot_version, c.history_version, c.queue_version, c.generation_attempt, c.retry_state, c.retry_state_version, c.runner_id, c.requires_action_deadline_at, c.user_acl, c.group_acl, c.owner_username, c.owner_name, c.context_aggregate_hash, c.context_dirty_since, c.context_dirty_resources, c.context_error, c.compaction_requested_at, + ( + GREATEST( + 0, + params.max_age_seconds::bigint - FLOOR(EXTRACT(EPOCH FROM NOW() - g.started_at))::bigint + ) * 1000 + )::bigint AS remaining_ms FROM chat_summary_generations g JOIN chats_expanded c ON c.id = g.chat_id +CROSS JOIN params WHERE c.owner_id = $1::uuid AND c.parent_chat_id IS NULL - AND g.started_at > NOW() - (INTERVAL '1 second' * $2::int) + AND g.started_at > NOW() - (INTERVAL '1 second' * params.max_age_seconds) ORDER BY g.started_at ` @@ -7732,63 +7743,69 @@ type GetActiveChatSummaryGenerationsByOwnerIDParams struct { MaxAgeSeconds int32 `db:"max_age_seconds" json:"max_age_seconds"` } -func (q *sqlQuerier) GetActiveChatSummaryGenerationsByOwnerID(ctx context.Context, arg GetActiveChatSummaryGenerationsByOwnerIDParams) ([]Chat, error) { +type GetActiveChatSummaryGenerationsByOwnerIDRow struct { + Chat Chat `db:"chat" json:"chat"` + RemainingMs int64 `db:"remaining_ms" json:"remaining_ms"` +} + +func (q *sqlQuerier) GetActiveChatSummaryGenerationsByOwnerID(ctx context.Context, arg GetActiveChatSummaryGenerationsByOwnerIDParams) ([]GetActiveChatSummaryGenerationsByOwnerIDRow, error) { rows, err := q.db.QueryContext(ctx, getActiveChatSummaryGenerationsByOwnerID, arg.OwnerID, arg.MaxAgeSeconds) if err != nil { return nil, err } defer rows.Close() - var items []Chat + var items []GetActiveChatSummaryGenerationsByOwnerIDRow for rows.Next() { - var i Chat + var i GetActiveChatSummaryGenerationsByOwnerIDRow if err := rows.Scan( - &i.ID, - &i.OwnerID, - &i.WorkspaceID, - &i.Title, - &i.Status, - &i.WorkerID, - &i.StartedAt, - &i.HeartbeatAt, - &i.CreatedAt, - &i.UpdatedAt, - &i.ParentChatID, - &i.RootChatID, - &i.LastModelConfigID, - &i.LastReasoningEffort, - &i.Archived, - &i.LastError, - &i.Mode, - pq.Array(&i.MCPServerIDs), - &i.Labels, - &i.BuildID, - &i.AgentID, - &i.PinOrder, - &i.LastReadMessageID, - &i.DynamicTools, - &i.OrganizationID, - &i.PlanMode, - &i.ClientType, - &i.LastTurnSummary, - &i.Summary, - &i.SummaryGeneratedAt, - &i.SnapshotVersion, - &i.HistoryVersion, - &i.QueueVersion, - &i.GenerationAttempt, - &i.RetryState, - &i.RetryStateVersion, - &i.RunnerID, - &i.RequiresActionDeadlineAt, - &i.UserACL, - &i.GroupACL, - &i.OwnerUsername, - &i.OwnerName, - &i.ContextAggregateHash, - &i.ContextDirtySince, - &i.ContextDirtyResources, - &i.ContextError, - &i.CompactionRequestedAt, + &i.Chat.ID, + &i.Chat.OwnerID, + &i.Chat.WorkspaceID, + &i.Chat.Title, + &i.Chat.Status, + &i.Chat.WorkerID, + &i.Chat.StartedAt, + &i.Chat.HeartbeatAt, + &i.Chat.CreatedAt, + &i.Chat.UpdatedAt, + &i.Chat.ParentChatID, + &i.Chat.RootChatID, + &i.Chat.LastModelConfigID, + &i.Chat.LastReasoningEffort, + &i.Chat.Archived, + &i.Chat.LastError, + &i.Chat.Mode, + pq.Array(&i.Chat.MCPServerIDs), + &i.Chat.Labels, + &i.Chat.BuildID, + &i.Chat.AgentID, + &i.Chat.PinOrder, + &i.Chat.LastReadMessageID, + &i.Chat.DynamicTools, + &i.Chat.OrganizationID, + &i.Chat.PlanMode, + &i.Chat.ClientType, + &i.Chat.LastTurnSummary, + &i.Chat.Summary, + &i.Chat.SummaryGeneratedAt, + &i.Chat.SnapshotVersion, + &i.Chat.HistoryVersion, + &i.Chat.QueueVersion, + &i.Chat.GenerationAttempt, + &i.Chat.RetryState, + &i.Chat.RetryStateVersion, + &i.Chat.RunnerID, + &i.Chat.RequiresActionDeadlineAt, + &i.Chat.UserACL, + &i.Chat.GroupACL, + &i.Chat.OwnerUsername, + &i.Chat.OwnerName, + &i.Chat.ContextAggregateHash, + &i.Chat.ContextDirtySince, + &i.Chat.ContextDirtyResources, + &i.Chat.ContextError, + &i.Chat.CompactionRequestedAt, + &i.RemainingMs, ); err != nil { return nil, err } diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index 57993bce1efe6..4deb22151705e 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -1488,13 +1488,24 @@ WHERE AND started_at = sqlc.arg('generation_started_at')::timestamptz; -- name: GetActiveChatSummaryGenerationsByOwnerID :many -SELECT c.* +WITH params AS ( + SELECT @max_age_seconds::int AS max_age_seconds +) +SELECT + sqlc.embed(c), + ( + GREATEST( + 0, + params.max_age_seconds::bigint - FLOOR(EXTRACT(EPOCH FROM NOW() - g.started_at))::bigint + ) * 1000 + )::bigint AS remaining_ms FROM chat_summary_generations g JOIN chats_expanded c ON c.id = g.chat_id +CROSS JOIN params WHERE c.owner_id = @owner_id::uuid AND c.parent_chat_id IS NULL - AND g.started_at > NOW() - (INTERVAL '1 second' * @max_age_seconds::int) + AND g.started_at > NOW() - (INTERVAL '1 second' * params.max_age_seconds) ORDER BY g.started_at; -- name: UpdateChatSummary :execrows diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 9fc14280dc547..60888b45f3270 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -285,15 +285,17 @@ func (api *API) watchChats(rw http.ResponseWriter, r *http.Request) { ctx = api.wsWatcher.Watch(ctx, logger, conn) encoder = json.NewEncoder(wsNetConn) - for _, chat := range activeSummaryGenerations { + for _, generation := range activeSummaryGenerations { + remainingMs := generation.RemainingMs if err := encoder.Encode(codersdk.ChatWatchEvent{ - Kind: codersdk.ChatWatchEventKindChatSummaryGenerating, - Chat: db2sdk.Chat(chat, nil, nil), + Kind: codersdk.ChatWatchEventKindChatSummaryGenerating, + Chat: db2sdk.Chat(generation.Chat, nil, nil), + ChatSummaryGenerationRemainingMS: &remainingMs, }); err != nil { encoder = nil close(encoderReady) logger.Debug(ctx, "failed to replay chat summary generation", - slog.F("chat_id", chat.ID), slog.Error(err)) + slog.F("chat_id", generation.Chat.ID), slog.Error(err)) return } } diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 73ac7dcf47348..6b0544523b574 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -3187,6 +3187,13 @@ func TestWatchChats(t *testing.T) { require.NoError(t, wsjson.Read(ctx, conn, &payload)) require.Equal(t, codersdk.ChatWatchEventKindChatSummaryGenerating, payload.Kind) require.Equal(t, chat.ID, payload.Chat.ID) + require.NotNil(t, payload.ChatSummaryGenerationRemainingMS) + require.Positive(t, *payload.ChatSummaryGenerationRemainingMS) + require.LessOrEqual( + t, + *payload.ChatSummaryGenerationRemainingMS, + codersdk.ChatSummaryGenerationTimeout.Milliseconds(), + ) }) t.Run("CreatedEventIncludesAllChatFields", func(t *testing.T) { diff --git a/codersdk/chats.go b/codersdk/chats.go index 6d76945dc93df..01772ee94c0a7 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -1893,9 +1893,12 @@ const ( // ActionRequired, ToolCalls contains the pending dynamic tool // invocations the client must execute and submit back. type ChatWatchEvent struct { - Kind ChatWatchEventKind `json:"kind"` - Chat Chat `json:"chat"` - ToolCalls []ChatStreamToolCall `json:"tool_calls,omitempty"` + Kind ChatWatchEventKind `json:"kind"` + Chat Chat `json:"chat"` + // ChatSummaryGenerationRemainingMS is present on chat_summary_generating + // events so clients do not restart the generation timeout after reconnecting. + ChatSummaryGenerationRemainingMS *int64 `json:"chat_summary_generation_remaining_ms,omitempty"` + ToolCalls []ChatStreamToolCall `json:"tool_calls,omitempty"` } // ChatStreamEvent represents a real-time update for chat streaming. diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index 420696a4e4f3a..9c0de6c354426 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -719,6 +719,7 @@ Experimental: this endpoint is subject to change. ], "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" }, + "chat_summary_generation_remaining_ms": 0, "kind": "status_change", "tool_calls": [ { diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 59c0d8502527c..615c07450700c 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -5312,6 +5312,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ], "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" }, + "chat_summary_generation_remaining_ms": 0, "kind": "status_change", "tool_calls": [ { @@ -5325,11 +5326,12 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------|---------------------------------------------------------------------|----------|--------------|-------------| -| `chat` | [codersdk.Chat](#codersdkchat) | false | | | -| `kind` | [codersdk.ChatWatchEventKind](#codersdkchatwatcheventkind) | false | | | -| `tool_calls` | array of [codersdk.ChatStreamToolCall](#codersdkchatstreamtoolcall) | false | | | +| Name | Type | Required | Restrictions | Description | +|----------------------------------------|---------------------------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------| +| `chat` | [codersdk.Chat](#codersdkchat) | false | | | +| `chat_summary_generation_remaining_ms` | integer | false | | Chat summary generation remaining ms is present on chat_summary_generating events so clients do not restart the generation timeout after reconnecting. | +| `kind` | [codersdk.ChatWatchEventKind](#codersdkchatwatcheventkind) | false | | | +| `tool_calls` | array of [codersdk.ChatStreamToolCall](#codersdkchatstreamtoolcall) | false | | | ## codersdk.ChatWatchEventKind diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index c7c0c15b11665..b6881fc242275 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3622,6 +3622,11 @@ export interface ChatUser extends MinimalUser { export interface ChatWatchEvent { readonly kind: ChatWatchEventKind; readonly chat: Chat; + /** + * ChatSummaryGenerationRemainingMS is present on chat_summary_generating + * events so clients do not restart the generation timeout after reconnecting. + */ + readonly chat_summary_generation_remaining_ms?: number; readonly tool_calls?: readonly ChatStreamToolCall[]; } diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx index 165c0a14985e3..6f7aea88d6d50 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx @@ -1061,9 +1061,14 @@ const chatWatchEvent = ( kind: TypesGen.ChatWatchEventKind, chat: Chat, delayMs = 0, + chatSummaryGenerationRemainingMs?: number, ) => ({ event: "message" as const, - data: JSON.stringify({ kind, chat } satisfies TypesGen.ChatWatchEvent), + data: JSON.stringify({ + kind, + chat, + chat_summary_generation_remaining_ms: chatSummaryGenerationRemainingMs, + } satisfies TypesGen.ChatWatchEvent), delayMs, }); @@ -1143,6 +1148,44 @@ export const SummaryWatchEventsUpdateOpenPanel: Story = { }, }; +export const SummaryReplayUsesRemainingTimeout: Story = { + decorators: [withProxyProvider()], + beforeEach: () => { + mockChats([watchedChat()]); + const cleanup = mockAgentChatPageAPIs(); + clearPersistedSidebarTabId(WATCHED_CHAT_ID); + localStorage.setItem(RIGHT_PANEL_OPEN_KEY, "true"); + return () => { + clearPersistedSidebarTabId(WATCHED_CHAT_ID); + cleanup(); + }; + }, + parameters: watchedChatPageParameters(watchedChat(), [ + chatWatchEvent("chat_summary_generating", watchedChat(), 750, 1_000), + ]), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const summaryPanel = await canvas.findByRole("tabpanel", { + name: "Summary", + }); + const summary = within(summaryPanel); + expect( + await summary.findByText("Not enough details to summarize."), + ).toBeVisible(); + expect( + await summary.findByRole("status", undefined, { timeout: 3_000 }), + ).toHaveTextContent("Generating summary"); + expect( + await summary.findByText( + "Not enough details to summarize.", + {}, + { timeout: 3_000 }, + ), + ).toBeVisible(); + expect(summary.queryByRole("status")).not.toBeInTheDocument(); + }, +}; + export const SummaryReconnectClearsStaleGeneratingState: Story = { decorators: [withProxyProvider()], beforeEach: () => { diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.tsx index 2634a353f6252..324d9445801d8 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.tsx @@ -587,11 +587,23 @@ const AgentsPageLayout: FC = () => { current.size === 0 ? current : new Set(), ); }; - const markSummaryGenerating = (chatId: string) => { + const markSummaryGenerating = ( + chatId: string, + timeoutMs = chatSummaryGeneratingTimeoutMs, + ) => { const previousTimeout = summaryGeneratingTimeouts.get(chatId); if (previousTimeout !== undefined) { clearTimeout(previousTimeout); } + const boundedTimeoutMs = Math.max( + 0, + Math.min(timeoutMs, chatSummaryGeneratingTimeoutMs), + ); + if (boundedTimeoutMs === 0) { + summaryGeneratingTimeouts.delete(chatId); + clearSummaryGenerating(chatId); + return; + } setSummaryGeneratingChatIds((current) => { if (current.has(chatId)) { return current; @@ -601,7 +613,7 @@ const AgentsPageLayout: FC = () => { const timeout = setTimeout(() => { summaryGeneratingTimeouts.delete(chatId); clearSummaryGenerating(chatId); - }, chatSummaryGeneratingTimeoutMs); + }, boundedTimeoutMs); summaryGeneratingTimeouts.set(chatId, timeout); }; @@ -617,7 +629,10 @@ const AgentsPageLayout: FC = () => { const chatEvent = event.parsedMessage; const updatedChat = chatEvent.chat; if (chatEvent.kind === "chat_summary_generating") { - markSummaryGenerating(updatedChat.id); + markSummaryGenerating( + updatedChat.id, + chatEvent.chat_summary_generation_remaining_ms, + ); } else if ( chatEvent.kind === "chat_summary_change" || chatEvent.kind === "chat_summary_failed" || From 840425e138d5ea72d37f5268be2790a741e72b50 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 27 Aug 2026 18:05:54 +0000 Subject: [PATCH 19/27] docs(coderd/x/chatd): add summary architecture TODOs --- coderd/x/chatd/ARCHITECTURE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index 9a773c9c56ea7..47cb59fd45ea5 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -564,6 +564,8 @@ No other input states are supported: generating chats get a conflict error, and ## Pubsub + + The chat worker and the stream loop need real-time notifications when the chat state changes to ensure they are responsive. To achieve this, we use pubsub. As with the transitions section, I don't recommend reading the rest of this section thoroughly at first. Give it a cursory look, and treat it as a reference that you can return to later when you're analyzing the `GET /api/experimental/chats/{chat}/stream` endpoint or the chat worker. @@ -601,6 +603,8 @@ There are 2 notification channels: # Chat worker + + A chat worker lives inside every coderd replica. It acquires chats, calls the LLM API, executes tools, handles interrupts and tool-result waits, and commits completed outcomes through the core state machine. From 91a04ced7ceb92973e104330fdb3a69514a50455 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 27 Aug 2026 18:23:36 +0000 Subject: [PATCH 20/27] fix: keep summary completion state consistent --- coderd/database/querier.go | 5 ++-- coderd/database/querier_test.go | 14 +++++------ coderd/database/queries.sql.go | 23 +++++++++++-------- coderd/database/queries/chats.sql | 23 +++++++++++-------- coderd/x/chatd/chatd.go | 4 ---- .../src/pages/AgentsPage/AgentsPageLayout.tsx | 14 ++++++----- 6 files changed, 43 insertions(+), 40 deletions(-) diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 104f9ab4a1eae..3cb6e2a89fabc 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1509,9 +1509,8 @@ type sqlcQuerier interface { UpdateChatRetryState(ctx context.Context, arg UpdateChatRetryStateParams) (Chat, error) UpdateChatStatus(ctx context.Context, arg UpdateChatStatusParams) (Chat, error) // The history_version fence lets background summary writes ignore worker-only - // updates while losing to newer message history. Root summary workers also pass - // their generation marker so an older overlapping worker cannot overwrite a - // newer attempt. + // updates while losing to newer message history. Root summary workers atomically + // delete their generation marker so storage and replay state cannot diverge. UpdateChatSummary(ctx context.Context, arg UpdateChatSummaryParams) (int64, error) UpdateChatTitleByID(ctx context.Context, arg UpdateChatTitleByIDParams) (Chat, error) UpdateChatWorkspaceBinding(ctx context.Context, arg UpdateChatWorkspaceBindingParams) (Chat, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index b54d1fe7e43b1..8c7f17efebbba 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -16219,13 +16219,6 @@ func TestUpdateChatSummary(t *testing.T) { require.True(t, fetched.SummaryGeneratedAt.Valid) require.Equal(t, chat.UpdatedAt, fetched.UpdatedAt) - cleared, err := db.ClearChatSummaryGeneration(ctx, database.ClearChatSummaryGenerationParams{ - ID: chat.ID, - GenerationStartedAt: generationStartedAt, - }) - require.NoError(t, err) - require.EqualValues(t, 1, cleared) - activeGenerations, err = db.GetActiveChatSummaryGenerationsByOwnerID(ctx, database.GetActiveChatSummaryGenerationsByOwnerIDParams{ OwnerID: owner.ID, MaxAgeSeconds: 60, @@ -16233,6 +16226,13 @@ func TestUpdateChatSummary(t *testing.T) { require.NoError(t, err) require.Empty(t, activeGenerations) + cleared, err := db.ClearChatSummaryGeneration(ctx, database.ClearChatSummaryGenerationParams{ + ID: chat.ID, + GenerationStartedAt: generationStartedAt, + }) + require.NoError(t, err) + require.Zero(t, cleared) + affected, err = db.UpdateChatSummary(ctx, database.UpdateChatSummaryParams{ ID: chat.ID, ExpectedHistoryVersion: chat.HistoryVersion, diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index de4de87563625..5d53aa4397adb 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -13208,6 +13208,16 @@ func (q *sqlQuerier) UpdateChatStatus(ctx context.Context, arg UpdateChatStatusP } const updateChatSummary = `-- name: UpdateChatSummary :execrows +WITH cleared_generation AS ( + DELETE FROM chat_summary_generations g + USING chats current_chat + WHERE + g.chat_id = $2::uuid + AND current_chat.id = g.chat_id + AND current_chat.history_version = $3::bigint + AND g.started_at = $4::timestamptz + RETURNING g.chat_id +) UPDATE chats SET summary = $1::text, @@ -13217,13 +13227,7 @@ WHERE AND history_version = $3::bigint AND ( $4::timestamptz IS NULL - OR EXISTS ( - SELECT 1 - FROM chat_summary_generations g - WHERE - g.chat_id = chats.id - AND g.started_at = $4::timestamptz - ) + OR EXISTS (SELECT 1 FROM cleared_generation) ) ` @@ -13235,9 +13239,8 @@ type UpdateChatSummaryParams struct { } // The history_version fence lets background summary writes ignore worker-only -// updates while losing to newer message history. Root summary workers also pass -// their generation marker so an older overlapping worker cannot overwrite a -// newer attempt. +// updates while losing to newer message history. Root summary workers atomically +// delete their generation marker so storage and replay state cannot diverge. func (q *sqlQuerier) UpdateChatSummary(ctx context.Context, arg UpdateChatSummaryParams) (int64, error) { result, err := q.db.ExecContext(ctx, updateChatSummary, arg.Summary, diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index 4deb22151705e..1999484e79d98 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -1510,9 +1510,18 @@ ORDER BY g.started_at; -- name: UpdateChatSummary :execrows -- The history_version fence lets background summary writes ignore worker-only --- updates while losing to newer message history. Root summary workers also pass --- their generation marker so an older overlapping worker cannot overwrite a --- newer attempt. +-- updates while losing to newer message history. Root summary workers atomically +-- delete their generation marker so storage and replay state cannot diverge. +WITH cleared_generation AS ( + DELETE FROM chat_summary_generations g + USING chats current_chat + WHERE + g.chat_id = @id::uuid + AND current_chat.id = g.chat_id + AND current_chat.history_version = @expected_history_version::bigint + AND g.started_at = sqlc.narg('expected_generation_started_at')::timestamptz + RETURNING g.chat_id +) UPDATE chats SET summary = sqlc.narg('summary')::text, @@ -1522,13 +1531,7 @@ WHERE AND history_version = @expected_history_version::bigint AND ( sqlc.narg('expected_generation_started_at')::timestamptz IS NULL - OR EXISTS ( - SELECT 1 - FROM chat_summary_generations g - WHERE - g.chat_id = chats.id - AND g.started_at = sqlc.narg('expected_generation_started_at')::timestamptz - ) + OR EXISTS (SELECT 1 FROM cleared_generation) ); -- name: UpdateChatMCPServerIDs :one diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 81d8172232f86..557ab8f7510d0 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -5043,10 +5043,6 @@ func (p *Server) updateChatSummary( return false } - if expectedGenerationStartedAt.Valid { - _ = p.clearChatSummaryGeneration(ctx, logger, chat.ID, expectedGenerationStartedAt.Time) - } - updatedChat := chat updatedChat.Summary = sqlSummary p.publishChatPubsubEvent(updatedChat, codersdk.ChatWatchEventKindChatSummaryChange, nil) diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.tsx index 324d9445801d8..fdbcc659d0d60 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.tsx @@ -645,12 +645,14 @@ const AgentsPageLayout: FC = () => { } clearSummaryGenerating(updatedChat.id); } - // The old membership is only available before the cache write below. - const prevStatus = readInfiniteChatsCache(queryClient)?.find( - (chat) => chat.id === updatedChat.id, - )?.status; - // Only play the chime for top-level chats, not sub-agents. - if (!updatedChat.parent_chat_id) { + if ( + chatEvent.kind === "status_change" && + !updatedChat.parent_chat_id + ) { + // The old membership is only available before the cache write below. + const prevStatus = readInfiniteChatsCache(queryClient)?.find( + (chat) => chat.id === updatedChat.id, + )?.status; maybePlayChime( prevStatus, updatedChat.status, From 99ef133e181805be2f67cf8a8fec2f7ff3eb2515 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 27 Aug 2026 18:36:35 +0000 Subject: [PATCH 21/27] fix(site): refetch active chat after reconnect --- site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx | 10 +++++++++- site/src/pages/AgentsPage/AgentsPageLayout.tsx | 1 + 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx index 6f7aea88d6d50..a8dffbb2bdbf7 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx @@ -5,6 +5,7 @@ import { expect, fireEvent, fn, + mocked, screen, spyOn, userEvent, @@ -1190,6 +1191,7 @@ export const SummaryReconnectClearsStaleGeneratingState: Story = { decorators: [withProxyProvider()], beforeEach: () => { mockChats([watchedChat()]); + spyOn(API.experimental, "getChat").mockResolvedValue(watchedChat()); const cleanup = mockAgentChatPageAPIs(); clearPersistedSidebarTabId(WATCHED_CHAT_ID); localStorage.setItem(RIGHT_PANEL_OPEN_KEY, "true"); @@ -1214,13 +1216,19 @@ export const SummaryReconnectClearsStaleGeneratingState: Story = { expect( await summary.findByRole("status", undefined, { timeout: 3_000 }), ).toHaveTextContent("Generating summary"); + const getChatMock = mocked(API.experimental.getChat); + getChatMock.mockResolvedValue( + watchedChat({ summary: "Summary completed while disconnected." }), + ); + const callsBeforeReconnect = getChatMock.mock.calls.length; expect( await summary.findByText( - "Not enough details to summarize.", + "Summary completed while disconnected.", {}, { timeout: 5_000 }, ), ).toBeVisible(); + expect(getChatMock.mock.calls.length).toBeGreaterThan(callsBeforeReconnect); expect(summary.queryByRole("status")).not.toBeInTheDocument(); }, }; diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.tsx index fdbcc659d0d60..483f1afa436c6 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.tsx @@ -756,6 +756,7 @@ const AgentsPageLayout: FC = () => { clearAllSummaryGenerating(); const activeChatId = activeChatIDRef.current; if (activeChatId) { + void invalidateChatEntity(queryClient, activeChatId); const activeChat = queryClient.getQueryData( chatEntityKey(activeChatId), ); From 2749c3d8ba169f6c912cb26515f75a1ddb3e8356 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 27 Aug 2026 19:16:19 +0000 Subject: [PATCH 22/27] fix: fence chat summary generation events --- coderd/apidoc/docs.go | 5 + coderd/apidoc/swagger.json | 5 + coderd/database/querier_test.go | 78 +++++++++++ coderd/database/queries.sql.go | 37 ++++-- coderd/database/queries/chats.sql | 23 ++-- coderd/exp_chats.go | 2 + coderd/exp_chats_test.go | 4 +- coderd/x/chatd/chatd.go | 53 ++++++-- coderd/x/chatd/chatd_internal_test.go | 14 +- codersdk/chats.go | 3 + docs/reference/api/chats.md | 1 + docs/reference/api/schemas.md | 2 + site/src/api/typesGenerated.ts | 5 + .../AgentsPage/AgentsPageLayout.stories.tsx | 124 +++++++++++++++++- .../src/pages/AgentsPage/AgentsPageLayout.tsx | 42 +++++- 15 files changed, 348 insertions(+), 50 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index c4fb8ec796cb3..5def6803c8f7a 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -20055,6 +20055,11 @@ const docTemplate = `{ "description": "ChatSummaryGenerationRemainingMS is present on chat_summary_generating\nevents so clients do not restart the generation timeout after reconnecting.", "type": "integer" }, + "chat_summary_generation_started_at": { + "description": "ChatSummaryGenerationStartedAt identifies the summary worker that emitted\ngenerating and terminal lifecycle events.", + "type": "string", + "format": "date-time" + }, "kind": { "$ref": "#/definitions/codersdk.ChatWatchEventKind" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index f74a1d8021a94..926118012659e 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -18162,6 +18162,11 @@ "description": "ChatSummaryGenerationRemainingMS is present on chat_summary_generating\nevents so clients do not restart the generation timeout after reconnecting.", "type": "integer" }, + "chat_summary_generation_started_at": { + "description": "ChatSummaryGenerationStartedAt identifies the summary worker that emitted\ngenerating and terminal lifecycle events.", + "type": "string", + "format": "date-time" + }, "kind": { "$ref": "#/definitions/codersdk.ChatWatchEventKind" }, diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 8c7f17efebbba..67f6caaf92ed6 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -16185,6 +16185,7 @@ func TestUpdateChatSummary(t *testing.T) { require.NoError(t, err) require.Len(t, activeGenerations, 1) require.Equal(t, chat.ID, activeGenerations[0].Chat.ID) + require.True(t, generationStartedAt.Equal(activeGenerations[0].GenerationStartedAt)) require.Positive(t, activeGenerations[0].RemainingMs) require.LessOrEqual(t, activeGenerations[0].RemainingMs, int64(60_000)) @@ -16289,6 +16290,83 @@ func TestUpdateChatSummary(t *testing.T) { require.NoError(t, err) require.Equal(t, sql.NullString{String: "Fresh whole-chat summary.", Valid: true}, fetched.Summary) require.NotEqual(t, chat.HistoryVersion, fetched.HistoryVersion) + + // A concurrent history update must win before the generation marker is + // deleted, otherwise reconnect replay loses track of the active worker. + generationStartedAt, err = db.StartChatSummaryGeneration(ctx, chat.ID) + require.NoError(t, err) + + historyTx, err := sqlDB.BeginTx(ctx, nil) + require.NoError(t, err) + historyTxCommitted := false + t.Cleanup(func() { + if !historyTxCommitted { + _ = historyTx.Rollback() + } + }) + var lockedChatID uuid.UUID + err = historyTx.QueryRowContext(ctx, ` +SELECT id +FROM chats +WHERE id = $1 +FOR UPDATE +`, chat.ID).Scan(&lockedChatID) + require.NoError(t, err) + require.Equal(t, chat.ID, lockedChatID) + + type summaryUpdateResult struct { + affected int64 + err error + } + updateResult := make(chan summaryUpdateResult, 1) + go func() { + affected, err := db.UpdateChatSummary(ctx, database.UpdateChatSummaryParams{ + ID: chat.ID, + ExpectedHistoryVersion: fetched.HistoryVersion, + ExpectedGenerationStartedAt: sql.NullTime{Time: generationStartedAt, Valid: true}, + Summary: sql.NullString{String: "Raced whole-chat summary.", Valid: true}, + }) + updateResult <- summaryUpdateResult{affected: affected, err: err} + }() + + var summaryLockWaits int + testutil.Eventually(ctx, t, func(ctx context.Context) bool { + err := historyTx.QueryRowContext(ctx, ` +SELECT COUNT(*) +FROM pg_stat_activity +WHERE datname = current_database() + AND pid <> pg_backend_pid() + AND query LIKE '%-- name: UpdateChatSummary%' + AND wait_event_type = 'Lock' +`).Scan(&summaryLockWaits) + return err == nil && summaryLockWaits == 1 + }, testutil.IntervalFast, "wait for summary update to reach the chat row lock") + require.NoError(t, ctx.Err(), "waiting for summary update") + + _, err = historyTx.ExecContext(ctx, ` +UPDATE chats +SET history_version = history_version + 1 +WHERE id = $1 +`, chat.ID) + require.NoError(t, err) + require.NoError(t, historyTx.Commit()) + historyTxCommitted = true + + select { + case result := <-updateResult: + require.NoError(t, result.err) + require.Zero(t, result.affected) + case <-ctx.Done(): + require.Failf(t, "summary update did not finish", "context ended: %v", ctx.Err()) + } + + activeGenerations, err = db.GetActiveChatSummaryGenerationsByOwnerID(ctx, database.GetActiveChatSummaryGenerationsByOwnerIDParams{ + OwnerID: owner.ID, + MaxAgeSeconds: 60, + }) + require.NoError(t, err) + require.Len(t, activeGenerations, 1) + require.True(t, generationStartedAt.Equal(activeGenerations[0].GenerationStartedAt)) } func TestUpdateChatWorkspaceBindingNoOp(t *testing.T) { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 5d53aa4397adb..f38c8565550fc 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -7727,7 +7727,8 @@ SELECT 0, params.max_age_seconds::bigint - FLOOR(EXTRACT(EPOCH FROM NOW() - g.started_at))::bigint ) * 1000 - )::bigint AS remaining_ms + )::bigint AS remaining_ms, + g.started_at AS generation_started_at FROM chat_summary_generations g JOIN chats_expanded c ON c.id = g.chat_id CROSS JOIN params @@ -7744,8 +7745,9 @@ type GetActiveChatSummaryGenerationsByOwnerIDParams struct { } type GetActiveChatSummaryGenerationsByOwnerIDRow struct { - Chat Chat `db:"chat" json:"chat"` - RemainingMs int64 `db:"remaining_ms" json:"remaining_ms"` + Chat Chat `db:"chat" json:"chat"` + RemainingMs int64 `db:"remaining_ms" json:"remaining_ms"` + GenerationStartedAt time.Time `db:"generation_started_at" json:"generation_started_at"` } func (q *sqlQuerier) GetActiveChatSummaryGenerationsByOwnerID(ctx context.Context, arg GetActiveChatSummaryGenerationsByOwnerIDParams) ([]GetActiveChatSummaryGenerationsByOwnerIDRow, error) { @@ -7806,6 +7808,7 @@ func (q *sqlQuerier) GetActiveChatSummaryGenerationsByOwnerID(ctx context.Contex &i.Chat.ContextError, &i.Chat.CompactionRequestedAt, &i.RemainingMs, + &i.GenerationStartedAt, ); err != nil { return nil, err } @@ -13208,34 +13211,40 @@ func (q *sqlQuerier) UpdateChatStatus(ctx context.Context, arg UpdateChatStatusP } const updateChatSummary = `-- name: UpdateChatSummary :execrows -WITH cleared_generation AS ( +WITH locked_chat AS ( + SELECT id + FROM chats + WHERE + id = $3::uuid + AND history_version = $4::bigint + FOR UPDATE +), +cleared_generation AS ( DELETE FROM chat_summary_generations g - USING chats current_chat + USING locked_chat WHERE - g.chat_id = $2::uuid - AND current_chat.id = g.chat_id - AND current_chat.history_version = $3::bigint - AND g.started_at = $4::timestamptz + g.chat_id = locked_chat.id + AND g.started_at = $2::timestamptz RETURNING g.chat_id ) UPDATE chats SET summary = $1::text, summary_generated_at = NOW() +FROM locked_chat WHERE - id = $2::uuid - AND history_version = $3::bigint + chats.id = locked_chat.id AND ( - $4::timestamptz IS NULL + $2::timestamptz IS NULL OR EXISTS (SELECT 1 FROM cleared_generation) ) ` type UpdateChatSummaryParams struct { Summary sql.NullString `db:"summary" json:"summary"` + ExpectedGenerationStartedAt sql.NullTime `db:"expected_generation_started_at" json:"expected_generation_started_at"` ID uuid.UUID `db:"id" json:"id"` ExpectedHistoryVersion int64 `db:"expected_history_version" json:"expected_history_version"` - ExpectedGenerationStartedAt sql.NullTime `db:"expected_generation_started_at" json:"expected_generation_started_at"` } // The history_version fence lets background summary writes ignore worker-only @@ -13244,9 +13253,9 @@ type UpdateChatSummaryParams struct { func (q *sqlQuerier) UpdateChatSummary(ctx context.Context, arg UpdateChatSummaryParams) (int64, error) { result, err := q.db.ExecContext(ctx, updateChatSummary, arg.Summary, + arg.ExpectedGenerationStartedAt, arg.ID, arg.ExpectedHistoryVersion, - arg.ExpectedGenerationStartedAt, ) if err != nil { return 0, err diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index 1999484e79d98..693b7e863c03d 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -1498,7 +1498,8 @@ SELECT 0, params.max_age_seconds::bigint - FLOOR(EXTRACT(EPOCH FROM NOW() - g.started_at))::bigint ) * 1000 - )::bigint AS remaining_ms + )::bigint AS remaining_ms, + g.started_at AS generation_started_at FROM chat_summary_generations g JOIN chats_expanded c ON c.id = g.chat_id CROSS JOIN params @@ -1512,13 +1513,19 @@ ORDER BY g.started_at; -- The history_version fence lets background summary writes ignore worker-only -- updates while losing to newer message history. Root summary workers atomically -- delete their generation marker so storage and replay state cannot diverge. -WITH cleared_generation AS ( +WITH locked_chat AS ( + SELECT id + FROM chats + WHERE + id = @id::uuid + AND history_version = @expected_history_version::bigint + FOR UPDATE +), +cleared_generation AS ( DELETE FROM chat_summary_generations g - USING chats current_chat + USING locked_chat WHERE - g.chat_id = @id::uuid - AND current_chat.id = g.chat_id - AND current_chat.history_version = @expected_history_version::bigint + g.chat_id = locked_chat.id AND g.started_at = sqlc.narg('expected_generation_started_at')::timestamptz RETURNING g.chat_id ) @@ -1526,9 +1533,9 @@ UPDATE chats SET summary = sqlc.narg('summary')::text, summary_generated_at = NOW() +FROM locked_chat WHERE - id = @id::uuid - AND history_version = @expected_history_version::bigint + chats.id = locked_chat.id AND ( sqlc.narg('expected_generation_started_at')::timestamptz IS NULL OR EXISTS (SELECT 1 FROM cleared_generation) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 60888b45f3270..9d7c8ef35e671 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -286,10 +286,12 @@ func (api *API) watchChats(rw http.ResponseWriter, r *http.Request) { encoder = json.NewEncoder(wsNetConn) for _, generation := range activeSummaryGenerations { + generationStartedAt := generation.GenerationStartedAt remainingMs := generation.RemainingMs if err := encoder.Encode(codersdk.ChatWatchEvent{ Kind: codersdk.ChatWatchEventKindChatSummaryGenerating, Chat: db2sdk.Chat(generation.Chat, nil, nil), + ChatSummaryGenerationStartedAt: &generationStartedAt, ChatSummaryGenerationRemainingMS: &remainingMs, }); err != nil { encoder = nil diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 6b0544523b574..d9e402a87f2a9 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -3173,7 +3173,7 @@ func TestWatchChats(t *testing.T) { LastModelConfigID: modelConfig.ID, }) - _, err := api.Database.StartChatSummaryGeneration( + generationStartedAt, err := api.Database.StartChatSummaryGeneration( dbauthz.AsChatd(ctx), chat.ID, ) @@ -3187,6 +3187,8 @@ func TestWatchChats(t *testing.T) { require.NoError(t, wsjson.Read(ctx, conn, &payload)) require.Equal(t, codersdk.ChatWatchEventKindChatSummaryGenerating, payload.Kind) require.Equal(t, chat.ID, payload.Chat.ID) + require.NotNil(t, payload.ChatSummaryGenerationStartedAt) + require.True(t, generationStartedAt.Equal(*payload.ChatSummaryGenerationStartedAt)) require.NotNil(t, payload.ChatSummaryGenerationRemainingMS) require.Positive(t, *payload.ChatSummaryGenerationRemainingMS) require.LessOrEqual( diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 557ab8f7510d0..71d105580167c 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -3341,13 +3341,7 @@ func chatWatchEventSDKChat(chat database.Chat, diffStatus *codersdk.ChatDiffStat return sdkChat } -// publishChatPubsubEvent broadcasts a chat lifecycle event via PostgreSQL -// pubsub so that all replicas can push updates to watching clients. -func (p *Server) publishChatPubsubEvent(chat database.Chat, kind codersdk.ChatWatchEventKind, diffStatus *codersdk.ChatDiffStatus) { - event := codersdk.ChatWatchEvent{ - Kind: kind, - Chat: chatWatchEventSDKChat(chat, diffStatus), - } +func (p *Server) publishChatWatchEvent(chat database.Chat, event codersdk.ChatWatchEvent) { payload, err := json.Marshal(event) if err != nil { p.logger.Error(context.Background(), "failed to marshal chat pubsub event", @@ -3359,12 +3353,33 @@ func (p *Server) publishChatPubsubEvent(chat database.Chat, kind codersdk.ChatWa if err := p.pubsub.Publish(coderdpubsub.ChatWatchEventChannel(chat.OwnerID), payload); err != nil { p.logger.Error(context.Background(), "failed to publish chat pubsub event", slog.F("chat_id", chat.ID), - slog.F("kind", kind), + slog.F("kind", event.Kind), slog.Error(err), ) } } +// publishChatPubsubEvent broadcasts a chat lifecycle event via PostgreSQL +// pubsub so that all replicas can push updates to watching clients. +func (p *Server) publishChatPubsubEvent(chat database.Chat, kind codersdk.ChatWatchEventKind, diffStatus *codersdk.ChatDiffStatus) { + p.publishChatWatchEvent(chat, codersdk.ChatWatchEvent{ + Kind: kind, + Chat: chatWatchEventSDKChat(chat, diffStatus), + }) +} + +func (p *Server) publishChatSummaryGenerationEvent( + chat database.Chat, + kind codersdk.ChatWatchEventKind, + generationStartedAt time.Time, +) { + p.publishChatWatchEvent(chat, codersdk.ChatWatchEvent{ + Kind: kind, + Chat: chatWatchEventSDKChat(chat, nil), + ChatSummaryGenerationStartedAt: &generationStartedAt, + }) +} + // ChatQueuedForCapacity reports whether the chat is waiting for a // concurrent-agent capacity slot. Uncapped deployments always return false. func (p *Server) ChatQueuedForCapacity(ctx context.Context, chat database.Chat) (bool, error) { @@ -4902,7 +4917,11 @@ func (p *Server) generateAndStoreChatSummary( } }() - p.publishChatPubsubEvent(chat, codersdk.ChatWatchEventKindChatSummaryGenerating, nil) + p.publishChatSummaryGenerationEvent( + chat, + codersdk.ChatWatchEventKindChatSummaryGenerating, + generationStartedAt, + ) summaryCtx, cancelGen := context.WithTimeout(ctx, chatSummaryGenerateTimeout) defer cancelGen() @@ -4999,7 +5018,11 @@ func (p *Server) failChatSummaryGeneration( generationStartedAt time.Time, ) { if p.clearChatSummaryGeneration(ctx, logger, chat.ID, generationStartedAt) { - p.publishChatPubsubEvent(chat, codersdk.ChatWatchEventKindChatSummaryFailed, nil) + p.publishChatSummaryGenerationEvent( + chat, + codersdk.ChatWatchEventKindChatSummaryFailed, + generationStartedAt, + ) } } @@ -5045,7 +5068,15 @@ func (p *Server) updateChatSummary( updatedChat := chat updatedChat.Summary = sqlSummary - p.publishChatPubsubEvent(updatedChat, codersdk.ChatWatchEventKindChatSummaryChange, nil) + if expectedGenerationStartedAt.Valid { + p.publishChatSummaryGenerationEvent( + updatedChat, + codersdk.ChatWatchEventKindChatSummaryChange, + expectedGenerationStartedAt.Time, + ) + } else { + p.publishChatPubsubEvent(updatedChat, codersdk.ChatWatchEventKindChatSummaryChange, nil) + } return true } diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index a7009a1d58d92..085b9315ed48e 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -112,6 +112,8 @@ func TestFailChatSummaryGeneration(t *testing.T) { events := ps.watchEvents(t) require.Len(t, events, 1) require.Equal(t, codersdk.ChatWatchEventKindChatSummaryFailed, events[0].Kind) + require.NotNil(t, events[0].ChatSummaryGenerationStartedAt) + require.True(t, generationStartedAt.Equal(*events[0].ChatSummaryGenerationStartedAt)) } func TestFailChatSummaryGenerationSuperseded(t *testing.T) { @@ -152,6 +154,7 @@ func TestUpdateChatSummary(t *testing.T) { server := &Server{db: db, pubsub: ps} chat := database.Chat{ID: uuid.New(), OwnerID: uuid.New(), HistoryVersion: 7} logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + generationStartedAt := time.Now() caller := rbac.Subject{ ID: chat.OwnerID.String(), Type: rbac.SubjectTypeUser, @@ -161,9 +164,10 @@ func TestUpdateChatSummary(t *testing.T) { ctx := dbauthz.As(context.Background(), caller) db.EXPECT().UpdateChatSummary(gomock.Any(), database.UpdateChatSummaryParams{ - ID: chat.ID, - ExpectedHistoryVersion: chat.HistoryVersion, - Summary: sql.NullString{String: "trimmed summary", Valid: true}, + ID: chat.ID, + ExpectedHistoryVersion: chat.HistoryVersion, + ExpectedGenerationStartedAt: sql.NullTime{Time: generationStartedAt, Valid: true}, + Summary: sql.NullString{String: "trimmed summary", Valid: true}, }).DoAndReturn(func(ctx context.Context, _ database.UpdateChatSummaryParams) (int64, error) { actor, ok := dbauthz.ActorFromContext(ctx) require.True(t, ok, "summary writes must preserve the caller's actor") @@ -171,11 +175,13 @@ func TestUpdateChatSummary(t *testing.T) { return 1, nil }) - server.updateChatSummary(ctx, logger, chat, chat.HistoryVersion, sql.NullTime{}, " \n trimmed summary\t ") + server.updateChatSummary(ctx, logger, chat, chat.HistoryVersion, sql.NullTime{Time: generationStartedAt, Valid: true}, " \n trimmed summary\t ") events := ps.watchEvents(t) require.Len(t, events, 1) require.Equal(t, codersdk.ChatWatchEventKindChatSummaryChange, events[0].Kind) + require.NotNil(t, events[0].ChatSummaryGenerationStartedAt) + require.True(t, generationStartedAt.Equal(*events[0].ChatSummaryGenerationStartedAt)) require.NotNil(t, events[0].Chat.Summary) require.Equal(t, "trimmed summary", *events[0].Chat.Summary) }) diff --git a/codersdk/chats.go b/codersdk/chats.go index 01772ee94c0a7..2736f3365d667 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -1895,6 +1895,9 @@ const ( type ChatWatchEvent struct { Kind ChatWatchEventKind `json:"kind"` Chat Chat `json:"chat"` + // ChatSummaryGenerationStartedAt identifies the summary worker that emitted + // generating and terminal lifecycle events. + ChatSummaryGenerationStartedAt *time.Time `json:"chat_summary_generation_started_at,omitempty" format:"date-time"` // ChatSummaryGenerationRemainingMS is present on chat_summary_generating // events so clients do not restart the generation timeout after reconnecting. ChatSummaryGenerationRemainingMS *int64 `json:"chat_summary_generation_remaining_ms,omitempty"` diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index 9c0de6c354426..06da8c8e22118 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -720,6 +720,7 @@ Experimental: this endpoint is subject to change. "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" }, "chat_summary_generation_remaining_ms": 0, + "chat_summary_generation_started_at": "2019-08-24T14:15:22Z", "kind": "status_change", "tool_calls": [ { diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 615c07450700c..1df9baeeb5a5f 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -5313,6 +5313,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" }, "chat_summary_generation_remaining_ms": 0, + "chat_summary_generation_started_at": "2019-08-24T14:15:22Z", "kind": "status_change", "tool_calls": [ { @@ -5330,6 +5331,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in |----------------------------------------|---------------------------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------| | `chat` | [codersdk.Chat](#codersdkchat) | false | | | | `chat_summary_generation_remaining_ms` | integer | false | | Chat summary generation remaining ms is present on chat_summary_generating events so clients do not restart the generation timeout after reconnecting. | +| `chat_summary_generation_started_at` | string | false | | Chat summary generation started at identifies the summary worker that emitted generating and terminal lifecycle events. | | `kind` | [codersdk.ChatWatchEventKind](#codersdkchatwatcheventkind) | false | | | | `tool_calls` | array of [codersdk.ChatStreamToolCall](#codersdkchatstreamtoolcall) | false | | | diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index b6881fc242275..f99327453adc7 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3622,6 +3622,11 @@ export interface ChatUser extends MinimalUser { export interface ChatWatchEvent { readonly kind: ChatWatchEventKind; readonly chat: Chat; + /** + * ChatSummaryGenerationStartedAt identifies the summary worker that emitted + * generating and terminal lifecycle events. + */ + readonly chat_summary_generation_started_at?: string; /** * ChatSummaryGenerationRemainingMS is present on chat_summary_generating * events so clients do not restart the generation timeout after reconnecting. diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx index a8dffbb2bdbf7..45c60659a69fc 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx @@ -1018,6 +1018,8 @@ const agentsWithAgentChatPageRouting = { }; const WATCHED_CHAT_ID = "chat-watched"; +const WATCHED_SUMMARY_GENERATION_STARTED_AT = "2026-08-27T18:00:00.000Z"; +const NEXT_WATCHED_SUMMARY_GENERATION_STARTED_AT = "2026-08-27T18:00:01.000Z"; // MockChat is owned by MockUserOwner, so the page renders the owner view // (composer enabled unless archived) instead of the other-user banner. @@ -1063,12 +1065,14 @@ const chatWatchEvent = ( chat: Chat, delayMs = 0, chatSummaryGenerationRemainingMs?: number, + chatSummaryGenerationStartedAt?: string, ) => ({ event: "message" as const, data: JSON.stringify({ kind, chat, chat_summary_generation_remaining_ms: chatSummaryGenerationRemainingMs, + chat_summary_generation_started_at: chatSummaryGenerationStartedAt, } satisfies TypesGen.ChatWatchEvent), delayMs, }); @@ -1113,11 +1117,19 @@ export const SummaryWatchEventsUpdateOpenPanel: Story = { }; }, parameters: watchedChatPageParameters(watchedChat(), [ - chatWatchEvent("chat_summary_generating", watchedChat(), 750), + chatWatchEvent( + "chat_summary_generating", + watchedChat(), + 750, + undefined, + WATCHED_SUMMARY_GENERATION_STARTED_AT, + ), chatWatchEvent( "chat_summary_change", watchedChat({ summary: "Generated summary from the watch event." }), 3_000, + undefined, + WATCHED_SUMMARY_GENERATION_STARTED_AT, ), ]), play: async ({ canvasElement }) => { @@ -1162,7 +1174,13 @@ export const SummaryReplayUsesRemainingTimeout: Story = { }; }, parameters: watchedChatPageParameters(watchedChat(), [ - chatWatchEvent("chat_summary_generating", watchedChat(), 750, 1_000), + chatWatchEvent( + "chat_summary_generating", + watchedChat(), + 750, + 1_000, + WATCHED_SUMMARY_GENERATION_STARTED_AT, + ), ]), play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -1201,7 +1219,13 @@ export const SummaryReconnectClearsStaleGeneratingState: Story = { }; }, parameters: watchedChatPageParameters(watchedChat(), [ - chatWatchEvent("chat_summary_generating", watchedChat(), 750), + chatWatchEvent( + "chat_summary_generating", + watchedChat(), + 750, + undefined, + WATCHED_SUMMARY_GENERATION_STARTED_AT, + ), { event: "open", delayMs: 3_000 }, ]), play: async ({ canvasElement }) => { @@ -1233,6 +1257,84 @@ export const SummaryReconnectClearsStaleGeneratingState: Story = { }, }; +export const StaleSummaryFailureKeepsNewGeneration: Story = { + decorators: [withProxyProvider()], + beforeEach: () => { + mockChats([watchedChat()]); + const cleanup = mockAgentChatPageAPIs(); + clearPersistedSidebarTabId(WATCHED_CHAT_ID); + localStorage.setItem(RIGHT_PANEL_OPEN_KEY, "true"); + return () => { + clearPersistedSidebarTabId(WATCHED_CHAT_ID); + cleanup(); + }; + }, + parameters: watchedChatPageParameters(watchedChat(), [ + chatWatchEvent( + "chat_summary_generating", + watchedChat(), + 500, + undefined, + WATCHED_SUMMARY_GENERATION_STARTED_AT, + ), + chatWatchEvent( + "chat_summary_generating", + watchedChat(), + 1_000, + undefined, + NEXT_WATCHED_SUMMARY_GENERATION_STARTED_AT, + ), + chatWatchEvent( + "chat_summary_failed", + watchedChat(), + 1_500, + undefined, + WATCHED_SUMMARY_GENERATION_STARTED_AT, + ), + chatWatchEvent( + "title_change", + watchedChat({ title: "Stale failure ignored" }), + 2_000, + ), + chatWatchEvent( + "chat_summary_failed", + watchedChat(), + 3_500, + undefined, + NEXT_WATCHED_SUMMARY_GENERATION_STARTED_AT, + ), + ]), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const summaryPanel = await canvas.findByRole("tabpanel", { + name: "Summary", + }); + const summary = within(summaryPanel); + expect( + await summary.findByText("Not enough details to summarize."), + ).toBeVisible(); + expect( + await summary.findByRole("status", undefined, { timeout: 3_000 }), + ).toHaveTextContent("Generating summary"); + expect( + await canvas.findAllByText( + "Stale failure ignored", + {}, + { timeout: 4_000 }, + ), + ).not.toHaveLength(0); + expect(summary.getByRole("status")).toHaveTextContent("Generating summary"); + expect( + await summary.findByText( + "Not enough details to summarize.", + {}, + { timeout: 5_000 }, + ), + ).toBeVisible(); + expect(summary.queryByRole("status")).not.toBeInTheDocument(); + }, +}; + export const SummaryFailureClearsGeneratingState: Story = { decorators: [withProxyProvider()], beforeEach: () => { @@ -1246,8 +1348,20 @@ export const SummaryFailureClearsGeneratingState: Story = { }; }, parameters: watchedChatPageParameters(watchedChat(), [ - chatWatchEvent("chat_summary_generating", watchedChat(), 750), - chatWatchEvent("chat_summary_failed", watchedChat(), 3_000), + chatWatchEvent( + "chat_summary_generating", + watchedChat(), + 750, + undefined, + WATCHED_SUMMARY_GENERATION_STARTED_AT, + ), + chatWatchEvent( + "chat_summary_failed", + watchedChat(), + 3_000, + undefined, + WATCHED_SUMMARY_GENERATION_STARTED_AT, + ), ]), play: async ({ canvasElement }) => { const canvas = within(canvasElement); diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.tsx index 483f1afa436c6..4a53c2e7ad188 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.tsx @@ -568,7 +568,20 @@ const AgentsPageLayout: FC = () => { string, ReturnType >(); - const clearSummaryGenerating = (chatId: string) => { + const summaryGenerationStartedAt = new Map(); + const clearSummaryGenerating = ( + chatId: string, + expectedGenerationStartedAt?: string, + ) => { + const activeGenerationStartedAt = summaryGenerationStartedAt.get(chatId); + if ( + expectedGenerationStartedAt !== undefined && + activeGenerationStartedAt !== undefined && + expectedGenerationStartedAt !== activeGenerationStartedAt + ) { + return false; + } + summaryGenerationStartedAt.delete(chatId); setSummaryGeneratingChatIds((current) => { if (!current.has(chatId)) { return current; @@ -577,12 +590,14 @@ const AgentsPageLayout: FC = () => { next.delete(chatId); return next; }); + return true; }; const clearAllSummaryGenerating = () => { for (const timeout of summaryGeneratingTimeouts.values()) { clearTimeout(timeout); } summaryGeneratingTimeouts.clear(); + summaryGenerationStartedAt.clear(); setSummaryGeneratingChatIds((current) => current.size === 0 ? current : new Set(), ); @@ -590,11 +605,17 @@ const AgentsPageLayout: FC = () => { const markSummaryGenerating = ( chatId: string, timeoutMs = chatSummaryGeneratingTimeoutMs, + generationStartedAt?: string, ) => { const previousTimeout = summaryGeneratingTimeouts.get(chatId); if (previousTimeout !== undefined) { clearTimeout(previousTimeout); } + if (generationStartedAt === undefined) { + summaryGenerationStartedAt.delete(chatId); + } else { + summaryGenerationStartedAt.set(chatId, generationStartedAt); + } const boundedTimeoutMs = Math.max( 0, Math.min(timeoutMs, chatSummaryGeneratingTimeoutMs), @@ -612,7 +633,7 @@ const AgentsPageLayout: FC = () => { }); const timeout = setTimeout(() => { summaryGeneratingTimeouts.delete(chatId); - clearSummaryGenerating(chatId); + clearSummaryGenerating(chatId, generationStartedAt); }, boundedTimeoutMs); summaryGeneratingTimeouts.set(chatId, timeout); }; @@ -632,18 +653,25 @@ const AgentsPageLayout: FC = () => { markSummaryGenerating( updatedChat.id, chatEvent.chat_summary_generation_remaining_ms, + chatEvent.chat_summary_generation_started_at, ); } else if ( chatEvent.kind === "chat_summary_change" || chatEvent.kind === "chat_summary_failed" || chatEvent.kind === "deleted" ) { - const timeout = summaryGeneratingTimeouts.get(updatedChat.id); - if (timeout !== undefined) { - clearTimeout(timeout); - summaryGeneratingTimeouts.delete(updatedChat.id); + if ( + clearSummaryGenerating( + updatedChat.id, + chatEvent.chat_summary_generation_started_at, + ) + ) { + const timeout = summaryGeneratingTimeouts.get(updatedChat.id); + if (timeout !== undefined) { + clearTimeout(timeout); + summaryGeneratingTimeouts.delete(updatedChat.id); + } } - clearSummaryGenerating(updatedChat.id); } if ( chatEvent.kind === "status_change" && From 2d6a39b97a65d6f012736cebd8b2dff4dbea8756 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 27 Aug 2026 19:30:24 +0000 Subject: [PATCH 23/27] fix(site): refresh chat cost after summary timeout --- .../pages/AgentsPage/AgentsPageLayout.stories.tsx | 15 +++++++++++++++ site/src/pages/AgentsPage/AgentsPageLayout.tsx | 4 +++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx index 45c60659a69fc..ecc56dd244625 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx @@ -1020,6 +1020,12 @@ const agentsWithAgentChatPageRouting = { const WATCHED_CHAT_ID = "chat-watched"; const WATCHED_SUMMARY_GENERATION_STARTED_AT = "2026-08-27T18:00:00.000Z"; const NEXT_WATCHED_SUMMARY_GENERATION_STARTED_AT = "2026-08-27T18:00:01.000Z"; +const watchedChatCost: TypesGen.ChatCost = { + chat_id: WATCHED_CHAT_ID, + total_cost_micros: 1_250_000, + request_count: 8, + unpriced_request_count: 0, +}; // MockChat is owned by MockUserOwner, so the page renders the owner view // (composer enabled unless archived) instead of the other-user banner. @@ -1100,6 +1106,7 @@ const watchedChatPageParameters = ( const mockAgentChatPageAPIs = () => { localStorage.removeItem(RIGHT_PANEL_OPEN_KEY); spyOn(API, "getApiKey").mockRejectedValue(new Error("missing API key")); + spyOn(API.experimental, "getChatCost").mockResolvedValue(watchedChatCost); spyOn(API.experimental, "updateChat").mockResolvedValue(); return () => localStorage.removeItem(RIGHT_PANEL_OPEN_KEY); }; @@ -1194,6 +1201,11 @@ export const SummaryReplayUsesRemainingTimeout: Story = { expect( await summary.findByRole("status", undefined, { timeout: 3_000 }), ).toHaveTextContent("Generating summary"); + const getChatCostMock = mocked(API.experimental.getChatCost); + await waitFor(() => { + expect(getChatCostMock).toHaveBeenCalledWith(WATCHED_CHAT_ID); + }); + getChatCostMock.mockClear(); expect( await summary.findByText( "Not enough details to summarize.", @@ -1202,6 +1214,9 @@ export const SummaryReplayUsesRemainingTimeout: Story = { ), ).toBeVisible(); expect(summary.queryByRole("status")).not.toBeInTheDocument(); + await waitFor(() => { + expect(getChatCostMock).toHaveBeenCalledWith(WATCHED_CHAT_ID); + }); }, }; diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.tsx index 4a53c2e7ad188..6eab9431397bf 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.tsx @@ -633,7 +633,9 @@ const AgentsPageLayout: FC = () => { }); const timeout = setTimeout(() => { summaryGeneratingTimeouts.delete(chatId); - clearSummaryGenerating(chatId, generationStartedAt); + if (clearSummaryGenerating(chatId, generationStartedAt)) { + void invalidateChatCostTree(queryClient, chatId); + } }, boundedTimeoutMs); summaryGeneratingTimeouts.set(chatId, timeout); }; From b8c059d5d3a1843a7492ee07986717fbcba7d9cd Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 27 Aug 2026 19:51:56 +0000 Subject: [PATCH 24/27] fix: order chat summary generations --- coderd/database/dbauthz/dbauthz.go | 6 +-- coderd/database/dbauthz/dbauthz_test.go | 8 +++- coderd/database/dbmetrics/querymetrics.go | 2 +- coderd/database/dbmock/dbmock.go | 8 ++-- coderd/database/querier.go | 3 +- coderd/database/querier_test.go | 32 ++++++++++++++- coderd/database/queries.sql.go | 25 ++++++++++-- coderd/database/queries/chats.sql | 16 +++++++- coderd/exp_chats_test.go | 5 ++- coderd/x/chatd/chatd.go | 5 ++- .../AgentsPage/AgentsPageLayout.stories.tsx | 27 +++++++++++++ .../src/pages/AgentsPage/AgentsPageLayout.tsx | 40 ++++++++++++++----- 12 files changed, 147 insertions(+), 30 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 3621cc5e33d73..cae338e719a71 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -7339,15 +7339,15 @@ func (q *querier) SoftDeleteWorkspaceAgentsByWorkspaceID(ctx context.Context, wo return q.db.SoftDeleteWorkspaceAgentsByWorkspaceID(ctx, workspaceID) } -func (q *querier) StartChatSummaryGeneration(ctx context.Context, id uuid.UUID) (time.Time, error) { - chat, err := q.db.GetChatByID(ctx, id) +func (q *querier) StartChatSummaryGeneration(ctx context.Context, arg database.StartChatSummaryGenerationParams) (time.Time, error) { + chat, err := q.db.GetChatByID(ctx, arg.ID) if err != nil { return time.Time{}, err } if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { return time.Time{}, err } - return q.db.StartChatSummaryGeneration(ctx, id) + return q.db.StartChatSummaryGeneration(ctx, arg) } func (q *querier) TouchChatDebugRunUpdatedAt(ctx context.Context, arg database.TouchChatDebugRunUpdatedAtParams) error { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index a9086466153d2..3b47c1dfc7f18 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1965,10 +1965,14 @@ func (s *MethodTestSuite) TestChats() { })) s.Run("StartChatSummaryGeneration", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) + arg := database.StartChatSummaryGenerationParams{ + ID: chat.ID, + ExpectedHistoryVersion: chat.HistoryVersion, + } startedAt := time.Now() dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() - dbm.EXPECT().StartChatSummaryGeneration(gomock.Any(), chat.ID).Return(startedAt, nil).AnyTimes() - check.Args(chat.ID).Asserts(chat, policy.ActionUpdate).Returns(startedAt) + dbm.EXPECT().StartChatSummaryGeneration(gomock.Any(), arg).Return(startedAt, nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(startedAt) })) s.Run("ClearChatSummaryGeneration", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 109733d41795d..0a1c3f566ec09 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -5144,7 +5144,7 @@ func (m queryMetricsStore) SoftDeleteWorkspaceAgentsByWorkspaceID(ctx context.Co return r0 } -func (m queryMetricsStore) StartChatSummaryGeneration(ctx context.Context, id uuid.UUID) (time.Time, error) { +func (m queryMetricsStore) StartChatSummaryGeneration(ctx context.Context, id database.StartChatSummaryGenerationParams) (time.Time, error) { start := time.Now() r0, r1 := m.s.StartChatSummaryGeneration(ctx, id) m.queryLatencies.WithLabelValues("StartChatSummaryGeneration").Observe(time.Since(start).Seconds()) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index ed11be9f56485..30effcdd1b932 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -9767,18 +9767,18 @@ func (mr *MockStoreMockRecorder) SoftDeleteWorkspaceAgentsByWorkspaceID(ctx, wor } // StartChatSummaryGeneration mocks base method. -func (m *MockStore) StartChatSummaryGeneration(ctx context.Context, id uuid.UUID) (time.Time, error) { +func (m *MockStore) StartChatSummaryGeneration(ctx context.Context, arg database.StartChatSummaryGenerationParams) (time.Time, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "StartChatSummaryGeneration", ctx, id) + ret := m.ctrl.Call(m, "StartChatSummaryGeneration", ctx, arg) ret0, _ := ret[0].(time.Time) ret1, _ := ret[1].(error) return ret0, ret1 } // StartChatSummaryGeneration indicates an expected call of StartChatSummaryGeneration. -func (mr *MockStoreMockRecorder) StartChatSummaryGeneration(ctx, id any) *gomock.Call { +func (mr *MockStoreMockRecorder) StartChatSummaryGeneration(ctx, arg any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartChatSummaryGeneration", reflect.TypeOf((*MockStore)(nil).StartChatSummaryGeneration), ctx, id) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartChatSummaryGeneration", reflect.TypeOf((*MockStore)(nil).StartChatSummaryGeneration), ctx, arg) } // TouchChatDebugRunUpdatedAt mocks base method. diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 3cb6e2a89fabc..acb4889c6c9ff 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1398,7 +1398,8 @@ type sqlcQuerier interface { // Agent context rows are hard-deleted for the same reason as in // SoftDeletePriorWorkspaceAgents. SoftDeleteWorkspaceAgentsByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) error - StartChatSummaryGeneration(ctx context.Context, id uuid.UUID) (time.Time, error) + // Clients order generation identities at millisecond precision. + StartChatSummaryGeneration(ctx context.Context, arg StartChatSummaryGenerationParams) (time.Time, error) // Overrides updated_at on the parent run without touching any // other column. Used by tests that need to stamp a run with a // specific timestamp after the InsertChatDebugStep CTE has diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 67f6caaf92ed6..4b0f4064e801a 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -16175,7 +16175,10 @@ func TestUpdateChatSummary(t *testing.T) { require.False(t, chat.Summary.Valid) require.False(t, chat.SummaryGeneratedAt.Valid) - generationStartedAt, err := db.StartChatSummaryGeneration(ctx, chat.ID) + generationStartedAt, err := db.StartChatSummaryGeneration(ctx, database.StartChatSummaryGenerationParams{ + ID: chat.ID, + ExpectedHistoryVersion: chat.HistoryVersion, + }) require.NoError(t, err) activeGenerations, err := db.GetActiveChatSummaryGenerationsByOwnerID(ctx, database.GetActiveChatSummaryGenerationsByOwnerIDParams{ @@ -16189,6 +16192,28 @@ func TestUpdateChatSummary(t *testing.T) { require.Positive(t, activeGenerations[0].RemainingMs) require.LessOrEqual(t, activeGenerations[0].RemainingMs, int64(60_000)) + previousGenerationStartedAt := generationStartedAt + generationStartedAt, err = db.StartChatSummaryGeneration(ctx, database.StartChatSummaryGenerationParams{ + ID: chat.ID, + ExpectedHistoryVersion: chat.HistoryVersion, + }) + require.NoError(t, err) + require.GreaterOrEqual(t, generationStartedAt.Sub(previousGenerationStartedAt), time.Millisecond) + + _, err = db.StartChatSummaryGeneration(ctx, database.StartChatSummaryGenerationParams{ + ID: chat.ID, + ExpectedHistoryVersion: chat.HistoryVersion - 1, + }) + require.ErrorIs(t, err, sql.ErrNoRows) + + activeGenerations, err = db.GetActiveChatSummaryGenerationsByOwnerID(ctx, database.GetActiveChatSummaryGenerationsByOwnerIDParams{ + OwnerID: owner.ID, + MaxAgeSeconds: 60, + }) + require.NoError(t, err) + require.Len(t, activeGenerations, 1) + require.True(t, generationStartedAt.Equal(activeGenerations[0].GenerationStartedAt)) + expiredGenerations, err := db.GetActiveChatSummaryGenerationsByOwnerID(ctx, database.GetActiveChatSummaryGenerationsByOwnerIDParams{ OwnerID: owner.ID, MaxAgeSeconds: 0, @@ -16293,7 +16318,10 @@ func TestUpdateChatSummary(t *testing.T) { // A concurrent history update must win before the generation marker is // deleted, otherwise reconnect replay loses track of the active worker. - generationStartedAt, err = db.StartChatSummaryGeneration(ctx, chat.ID) + generationStartedAt, err = db.StartChatSummaryGeneration(ctx, database.StartChatSummaryGenerationParams{ + ID: chat.ID, + ExpectedHistoryVersion: fetched.HistoryVersion, + }) require.NoError(t, err) historyTx, err := sqlDB.BeginTx(ctx, nil) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index f38c8565550fc..3b65ed43952aa 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -11604,15 +11604,32 @@ func (q *sqlQuerier) SoftDeleteContextFileMessages(ctx context.Context, chatID u } const startChatSummaryGeneration = `-- name: StartChatSummaryGeneration :one +WITH locked_chat AS ( + SELECT id + FROM chats + WHERE + id = $1::uuid + AND history_version = $2::bigint + FOR UPDATE +) INSERT INTO chat_summary_generations (chat_id) -VALUES ($1::uuid) +SELECT id FROM locked_chat ON CONFLICT (chat_id) DO UPDATE -SET started_at = NOW() +SET started_at = GREATEST( + NOW(), + chat_summary_generations.started_at + INTERVAL '1 millisecond' +) RETURNING started_at ` -func (q *sqlQuerier) StartChatSummaryGeneration(ctx context.Context, id uuid.UUID) (time.Time, error) { - row := q.db.QueryRowContext(ctx, startChatSummaryGeneration, id) +type StartChatSummaryGenerationParams struct { + ID uuid.UUID `db:"id" json:"id"` + ExpectedHistoryVersion int64 `db:"expected_history_version" json:"expected_history_version"` +} + +// Clients order generation identities at millisecond precision. +func (q *sqlQuerier) StartChatSummaryGeneration(ctx context.Context, arg StartChatSummaryGenerationParams) (time.Time, error) { + row := q.db.QueryRowContext(ctx, startChatSummaryGeneration, arg.ID, arg.ExpectedHistoryVersion) var started_at time.Time err := row.Scan(&started_at) return started_at, err diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index 693b7e863c03d..486ee5fa4d247 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -1475,10 +1475,22 @@ WHERE AND history_version = @expected_history_version::bigint; -- name: StartChatSummaryGeneration :one +WITH locked_chat AS ( + SELECT id + FROM chats + WHERE + id = @id::uuid + AND history_version = @expected_history_version::bigint + FOR UPDATE +) INSERT INTO chat_summary_generations (chat_id) -VALUES (@id::uuid) +SELECT id FROM locked_chat ON CONFLICT (chat_id) DO UPDATE -SET started_at = NOW() +-- Clients order generation identities at millisecond precision. +SET started_at = GREATEST( + NOW(), + chat_summary_generations.started_at + INTERVAL '1 millisecond' +) RETURNING started_at; -- name: ClearChatSummaryGeneration :execrows diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index d9e402a87f2a9..4053d58a3fde9 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -3175,7 +3175,10 @@ func TestWatchChats(t *testing.T) { generationStartedAt, err := api.Database.StartChatSummaryGeneration( dbauthz.AsChatd(ctx), - chat.ID, + database.StartChatSummaryGenerationParams{ + ID: chat.ID, + ExpectedHistoryVersion: chat.HistoryVersion, + }, ) require.NoError(t, err) diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 71d105580167c..f92ac8462511b 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -4904,7 +4904,10 @@ func (p *Server) generateAndStoreChatSummary( return } - generationStartedAt, err := p.db.StartChatSummaryGeneration(ctx, chat.ID) + generationStartedAt, err := p.db.StartChatSummaryGeneration(ctx, database.StartChatSummaryGenerationParams{ + ID: chat.ID, + ExpectedHistoryVersion: chat.HistoryVersion, + }) if err != nil { logger.Debug(ctx, "failed to mark chat summary generation", slog.F("chat_id", chat.ID), slog.Error(err)) diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx index ecc56dd244625..fd133c5e18ebf 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx @@ -1299,6 +1299,13 @@ export const StaleSummaryFailureKeepsNewGeneration: Story = { undefined, NEXT_WATCHED_SUMMARY_GENERATION_STARTED_AT, ), + chatWatchEvent( + "chat_summary_generating", + watchedChat(), + 1_250, + undefined, + WATCHED_SUMMARY_GENERATION_STARTED_AT, + ), chatWatchEvent( "chat_summary_failed", watchedChat(), @@ -1318,6 +1325,18 @@ export const StaleSummaryFailureKeepsNewGeneration: Story = { undefined, NEXT_WATCHED_SUMMARY_GENERATION_STARTED_AT, ), + chatWatchEvent( + "chat_summary_generating", + watchedChat(), + 4_000, + undefined, + WATCHED_SUMMARY_GENERATION_STARTED_AT, + ), + chatWatchEvent( + "title_change", + watchedChat({ title: "Old generation ignored after completion" }), + 4_500, + ), ]), play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -1347,6 +1366,14 @@ export const StaleSummaryFailureKeepsNewGeneration: Story = { ), ).toBeVisible(); expect(summary.queryByRole("status")).not.toBeInTheDocument(); + expect( + await canvas.findAllByText( + "Old generation ignored after completion", + {}, + { timeout: 6_000 }, + ), + ).not.toHaveLength(0); + expect(summary.queryByRole("status")).not.toBeInTheDocument(); }, }; diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.tsx index 6eab9431397bf..a2ba392744ad9 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.tsx @@ -568,20 +568,20 @@ const AgentsPageLayout: FC = () => { string, ReturnType >(); - const summaryGenerationStartedAt = new Map(); + const latestSummaryGenerationStartedAt = new Map(); const clearSummaryGenerating = ( chatId: string, expectedGenerationStartedAt?: string, ) => { - const activeGenerationStartedAt = summaryGenerationStartedAt.get(chatId); + const latestGenerationStartedAt = + latestSummaryGenerationStartedAt.get(chatId); if ( expectedGenerationStartedAt !== undefined && - activeGenerationStartedAt !== undefined && - expectedGenerationStartedAt !== activeGenerationStartedAt + latestGenerationStartedAt !== undefined && + expectedGenerationStartedAt !== latestGenerationStartedAt ) { return false; } - summaryGenerationStartedAt.delete(chatId); setSummaryGeneratingChatIds((current) => { if (!current.has(chatId)) { return current; @@ -597,7 +597,7 @@ const AgentsPageLayout: FC = () => { clearTimeout(timeout); } summaryGeneratingTimeouts.clear(); - summaryGenerationStartedAt.clear(); + latestSummaryGenerationStartedAt.clear(); setSummaryGeneratingChatIds((current) => current.size === 0 ? current : new Set(), ); @@ -607,14 +607,33 @@ const AgentsPageLayout: FC = () => { timeoutMs = chatSummaryGeneratingTimeoutMs, generationStartedAt?: string, ) => { + const previousGenerationStartedAt = + latestSummaryGenerationStartedAt.get(chatId); + if ( + generationStartedAt !== undefined && + previousGenerationStartedAt !== undefined + ) { + const generationStartedAtMs = Date.parse(generationStartedAt); + const previousGenerationStartedAtMs = Date.parse( + previousGenerationStartedAt, + ); + if ( + generationStartedAt === previousGenerationStartedAt || + (Number.isFinite(generationStartedAtMs) && + Number.isFinite(previousGenerationStartedAtMs) && + generationStartedAtMs <= previousGenerationStartedAtMs) + ) { + return; + } + } const previousTimeout = summaryGeneratingTimeouts.get(chatId); if (previousTimeout !== undefined) { clearTimeout(previousTimeout); } if (generationStartedAt === undefined) { - summaryGenerationStartedAt.delete(chatId); + latestSummaryGenerationStartedAt.delete(chatId); } else { - summaryGenerationStartedAt.set(chatId, generationStartedAt); + latestSummaryGenerationStartedAt.set(chatId, generationStartedAt); } const boundedTimeoutMs = Math.max( 0, @@ -622,7 +641,9 @@ const AgentsPageLayout: FC = () => { ); if (boundedTimeoutMs === 0) { summaryGeneratingTimeouts.delete(chatId); - clearSummaryGenerating(chatId); + if (clearSummaryGenerating(chatId, generationStartedAt)) { + void invalidateChatCostTree(queryClient, chatId); + } return; } setSummaryGeneratingChatIds((current) => { @@ -692,6 +713,7 @@ const AgentsPageLayout: FC = () => { } if (chatEvent.kind === "deleted") { + latestSummaryGenerationStartedAt.delete(updatedChat.id); // The server publishes `deleted` when a chat is // archived (one event per family member); there is // no hard-delete wire event. Patch archive state in From 1f29f74fae79fe91a5f37710fc965c825744d0a2 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 27 Aug 2026 20:08:32 +0000 Subject: [PATCH 25/27] test(site): exercise chat watch reconnect --- site/src/@types/storybook.d.ts | 13 +++++++++-- .../AgentsPage/AgentsPageLayout.stories.tsx | 13 +++++++++-- site/src/testHelpers/storybook.tsx | 22 +++++++++++++++---- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/site/src/@types/storybook.d.ts b/site/src/@types/storybook.d.ts index 1c125771d15c0..811da1e9424d3 100644 --- a/site/src/@types/storybook.d.ts +++ b/site/src/@types/storybook.d.ts @@ -13,8 +13,17 @@ import type { ReactRouterAddonStoryParameters } from "storybook-addon-remix-reac declare module "@storybook/react-vite" { type WebSocketEvent = - | { event: "message"; data: string; delayMs?: number } - | { event: "open" | "error" | "close"; delayMs?: number }; + | { + event: "message"; + data: string; + delayMs?: number; + connectionIndex?: number; + } + | { + event: "open" | "error" | "close"; + delayMs?: number; + connectionIndex?: number; + }; interface Parameters { features?: (FeatureName | ({ name: FeatureName } & Partial))[]; experiments?: Experiments; diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx index fd133c5e18ebf..2af04099863ae 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx @@ -1072,6 +1072,7 @@ const chatWatchEvent = ( delayMs = 0, chatSummaryGenerationRemainingMs?: number, chatSummaryGenerationStartedAt?: string, + connectionIndex?: number, ) => ({ event: "message" as const, data: JSON.stringify({ @@ -1081,13 +1082,18 @@ const chatWatchEvent = ( chat_summary_generation_started_at: chatSummaryGenerationStartedAt, } satisfies TypesGen.ChatWatchEvent), delayMs, + connectionIndex, }); const watchedChatPageParameters = ( chat: Chat, watchEvents: readonly ( | ReturnType - | { event: "open"; delayMs?: number } + | { + event: "open" | "close" | "error"; + delayMs?: number; + connectionIndex?: number; + } )[], ) => ({ queries: watchedChatQueries(chat), @@ -1234,14 +1240,17 @@ export const SummaryReconnectClearsStaleGeneratingState: Story = { }; }, parameters: watchedChatPageParameters(watchedChat(), [ + { event: "open", connectionIndex: 0 }, chatWatchEvent( "chat_summary_generating", watchedChat(), 750, undefined, WATCHED_SUMMARY_GENERATION_STARTED_AT, + 0, ), - { event: "open", delayMs: 3_000 }, + { event: "close", delayMs: 3_000, connectionIndex: 0 }, + { event: "open", connectionIndex: 1 }, ]), play: async ({ canvasElement }) => { const canvas = within(canvasElement); diff --git a/site/src/testHelpers/storybook.tsx b/site/src/testHelpers/storybook.tsx index 33f168d651f58..b41535c476294 100644 --- a/site/src/testHelpers/storybook.tsx +++ b/site/src/testHelpers/storybook.tsx @@ -90,6 +90,7 @@ type CallbackFn = (ev?: MessageEvent) => void; // } // // Events may set delayMs to defer delivery after listeners are registered. +// connectionIndex targets the zero-based socket created for a route. export const withWebSocket = (Story: FC, { parameters }: StoryContext) => { const param = parameters.webSocket; @@ -101,6 +102,7 @@ export const withWebSocket = (Story: FC, { parameters }: StoryContext) => { const isRouted = !Array.isArray(param); const broadcastEvents = isRouted ? [] : param; const routedEvents = isRouted ? param : {}; + const connectionCounts = new Map(); window.WebSocket = class WebSocket { public readyState = 1; @@ -109,10 +111,20 @@ export const withWebSocket = (Story: FC, { parameters }: StoryContext) => { #listeners = new Map(); #callEventsDelay: number | undefined; + #connectionIndex: number; + #routeKey: string | undefined; #url: string; constructor(url?: string) { this.#url = url ?? ""; + this.#routeKey = isRouted + ? Object.keys(routedEvents).find((key) => this.#url.includes(key)) + : undefined; + const connectionCountKey = isRouted + ? (this.#routeKey ?? this.#url) + : "broadcast"; + this.#connectionIndex = connectionCounts.get(connectionCountKey) ?? 0; + connectionCounts.set(connectionCountKey, this.#connectionIndex + 1); } send() {} @@ -123,11 +135,13 @@ export const withWebSocket = (Story: FC, { parameters }: StoryContext) => { // Determine which events this socket should receive. let events = broadcastEvents; if (isRouted) { - const matchingKey = Object.keys(routedEvents).find((key) => - this.#url.includes(key), - ); - events = matchingKey ? routedEvents[matchingKey] : []; + events = this.#routeKey ? routedEvents[this.#routeKey] : []; } + events = events.filter( + (entry) => + entry.connectionIndex === undefined || + entry.connectionIndex === this.#connectionIndex, + ); if (events.length === 0) { return; From 66dda5588da99cea0d58c596e40066375db06b4f Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 27 Aug 2026 20:17:36 +0000 Subject: [PATCH 26/27] fix(site): refetch summary after generation timeout --- site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx | 9 ++++++++- site/src/pages/AgentsPage/AgentsPageLayout.tsx | 2 ++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx index 2af04099863ae..75701289755a4 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx @@ -1178,6 +1178,7 @@ export const SummaryReplayUsesRemainingTimeout: Story = { decorators: [withProxyProvider()], beforeEach: () => { mockChats([watchedChat()]); + spyOn(API.experimental, "getChat").mockResolvedValue(watchedChat()); const cleanup = mockAgentChatPageAPIs(); clearPersistedSidebarTabId(WATCHED_CHAT_ID); localStorage.setItem(RIGHT_PANEL_OPEN_KEY, "true"); @@ -1212,14 +1213,20 @@ export const SummaryReplayUsesRemainingTimeout: Story = { expect(getChatCostMock).toHaveBeenCalledWith(WATCHED_CHAT_ID); }); getChatCostMock.mockClear(); + const getChatMock = mocked(API.experimental.getChat); + getChatMock.mockResolvedValue( + watchedChat({ summary: "Summary completed without a terminal event." }), + ); + getChatMock.mockClear(); expect( await summary.findByText( - "Not enough details to summarize.", + "Summary completed without a terminal event.", {}, { timeout: 3_000 }, ), ).toBeVisible(); expect(summary.queryByRole("status")).not.toBeInTheDocument(); + expect(getChatMock).toHaveBeenCalledWith(WATCHED_CHAT_ID); await waitFor(() => { expect(getChatCostMock).toHaveBeenCalledWith(WATCHED_CHAT_ID); }); diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.tsx index a2ba392744ad9..a6c51eb8ca105 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.tsx @@ -642,6 +642,7 @@ const AgentsPageLayout: FC = () => { if (boundedTimeoutMs === 0) { summaryGeneratingTimeouts.delete(chatId); if (clearSummaryGenerating(chatId, generationStartedAt)) { + void invalidateChatEntity(queryClient, chatId); void invalidateChatCostTree(queryClient, chatId); } return; @@ -655,6 +656,7 @@ const AgentsPageLayout: FC = () => { const timeout = setTimeout(() => { summaryGeneratingTimeouts.delete(chatId); if (clearSummaryGenerating(chatId, generationStartedAt)) { + void invalidateChatEntity(queryClient, chatId); void invalidateChatCostTree(queryClient, chatId); } }, boundedTimeoutMs); From c71462431caf11c906d95bd9c882fd47b49f6ed1 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 8 Sep 2026 13:59:23 +0000 Subject: [PATCH 27/27] refactor: remove durable chat summary loading state --- coderd/apidoc/docs.go | 13 - coderd/apidoc/swagger.json | 13 - coderd/database/dbauthz/dbauthz.go | 35 --- coderd/database/dbauthz/dbauthz_test.go | 34 --- coderd/database/dbmetrics/querymetrics.go | 24 -- coderd/database/dbmock/dbmock.go | 45 --- coderd/database/dump.sql | 11 - coderd/database/foreign_key_constraint.go | 1 - ...591_chat_summary_generation_state.down.sql | 1 - ...00591_chat_summary_generation_state.up.sql | 6 - ...00591_chat_summary_generation_state.up.sql | 10 - coderd/database/models.go | 5 - coderd/database/querier.go | 7 +- coderd/database/querier_test.go | 154 +--------- coderd/database/queries.sql.go | 200 +------------ coderd/database/queries/chats.sql | 74 +---- coderd/database/unique_constraint.go | 1 - coderd/exp_chats.go | 38 +-- coderd/exp_chats_test.go | 44 --- coderd/x/chatd/ARCHITECTURE.md | 4 - coderd/x/chatd/chatd.go | 128 ++------- coderd/x/chatd/chatd_internal_test.go | 72 +---- codersdk/chats.go | 30 +- docs/reference/api/chats.md | 2 - docs/reference/api/schemas.md | 20 +- site/src/api/queries/chats.test.ts | 4 - site/src/api/typesGenerated.ts | 14 - site/src/pages/AgentsPage/AgentChatPage.tsx | 4 - .../AgentsPage/AgentChatPageView.stories.tsx | 1 - .../pages/AgentsPage/AgentChatPageView.tsx | 3 - .../AgentsPage/AgentsPageLayout.stories.tsx | 267 +----------------- .../pages/AgentsPage/AgentsPageLayout.test.ts | 6 - .../src/pages/AgentsPage/AgentsPageLayout.tsx | 144 +--------- .../components/ChatSummary.stories.tsx | 42 +-- .../AgentsPage/components/ChatSummary.tsx | 44 +-- .../components/ChatSummaryPanel.stories.tsx | 48 +--- .../components/ChatSummaryPanel.tsx | 4 - 37 files changed, 75 insertions(+), 1478 deletions(-) delete mode 100644 coderd/database/migrations/000591_chat_summary_generation_state.down.sql delete mode 100644 coderd/database/migrations/000591_chat_summary_generation_state.up.sql delete mode 100644 coderd/database/migrations/testdata/fixtures/000591_chat_summary_generation_state.up.sql diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index ac219a3728b59..028deae32f5a2 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -21353,15 +21353,6 @@ const docTemplate = `{ "chat": { "$ref": "#/definitions/codersdk.Chat" }, - "chat_summary_generation_remaining_ms": { - "description": "ChatSummaryGenerationRemainingMS is present on chat_summary_generating\nevents so clients do not restart the generation timeout after reconnecting.", - "type": "integer" - }, - "chat_summary_generation_started_at": { - "description": "ChatSummaryGenerationStartedAt identifies the summary worker that emitted\ngenerating and terminal lifecycle events.", - "type": "string", - "format": "date-time" - }, "kind": { "$ref": "#/definitions/codersdk.ChatWatchEventKind" }, @@ -21379,8 +21370,6 @@ const docTemplate = `{ "status_change", "summary_change", "chat_summary_change", - "chat_summary_generating", - "chat_summary_failed", "title_change", "created", "deleted", @@ -21392,8 +21381,6 @@ const docTemplate = `{ "ChatWatchEventKindStatusChange", "ChatWatchEventKindSummaryChange", "ChatWatchEventKindChatSummaryChange", - "ChatWatchEventKindChatSummaryGenerating", - "ChatWatchEventKindChatSummaryFailed", "ChatWatchEventKindTitleChange", "ChatWatchEventKindCreated", "ChatWatchEventKindDeleted", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 5f65e89338a4d..05c91669ecada 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -19328,15 +19328,6 @@ "chat": { "$ref": "#/definitions/codersdk.Chat" }, - "chat_summary_generation_remaining_ms": { - "description": "ChatSummaryGenerationRemainingMS is present on chat_summary_generating\nevents so clients do not restart the generation timeout after reconnecting.", - "type": "integer" - }, - "chat_summary_generation_started_at": { - "description": "ChatSummaryGenerationStartedAt identifies the summary worker that emitted\ngenerating and terminal lifecycle events.", - "type": "string", - "format": "date-time" - }, "kind": { "$ref": "#/definitions/codersdk.ChatWatchEventKind" }, @@ -19354,8 +19345,6 @@ "status_change", "summary_change", "chat_summary_change", - "chat_summary_generating", - "chat_summary_failed", "title_change", "created", "deleted", @@ -19367,8 +19356,6 @@ "ChatWatchEventKindStatusChange", "ChatWatchEventKindSummaryChange", "ChatWatchEventKindChatSummaryChange", - "ChatWatchEventKindChatSummaryGenerating", - "ChatWatchEventKindChatSummaryFailed", "ChatWatchEventKindTitleChange", "ChatWatchEventKindCreated", "ChatWatchEventKindDeleted", diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 278c9d46f4947..e27731ab0fe07 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2004,17 +2004,6 @@ func (q *querier) ClearChatDiffStatusPR(ctx context.Context, arg database.ClearC return q.db.ClearChatDiffStatusPR(ctx, arg) } -func (q *querier) ClearChatSummaryGeneration(ctx context.Context, arg database.ClearChatSummaryGenerationParams) (int64, error) { - chat, err := q.db.GetChatByID(ctx, arg.ID) - if err != nil { - return 0, err - } - if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { - return 0, err - } - return q.db.ClearChatSummaryGeneration(ctx, arg) -} - func (q *querier) CountAIBridgeSessions(ctx context.Context, arg database.CountAIBridgeSessionsParams) (int64, error) { prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceAibridgeInterception.Type) if err != nil { @@ -3058,19 +3047,6 @@ func (q *querier) GetActiveAISeatCount(ctx context.Context) (int64, error) { return q.db.GetActiveAISeatCount(ctx) } -func (q *querier) GetActiveChatSummaryGenerationsByOwnerID(ctx context.Context, arg database.GetActiveChatSummaryGenerationsByOwnerIDParams) ([]database.GetActiveChatSummaryGenerationsByOwnerIDRow, error) { - rows, err := q.db.GetActiveChatSummaryGenerationsByOwnerID(ctx, arg) - if err != nil { - return nil, err - } - for _, row := range rows { - if err := q.authorizeContext(ctx, policy.ActionRead, row.Chat); err != nil { - return nil, err - } - } - return rows, nil -} - func (q *querier) GetActiveChatsByAgentID(ctx context.Context, agentID uuid.UUID) ([]database.Chat, error) { return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetActiveChatsByAgentID)(ctx, agentID) } @@ -7396,17 +7372,6 @@ func (q *querier) SoftDeleteWorkspaceAgentsByWorkspaceID(ctx context.Context, wo return q.db.SoftDeleteWorkspaceAgentsByWorkspaceID(ctx, workspaceID) } -func (q *querier) StartChatSummaryGeneration(ctx context.Context, arg database.StartChatSummaryGenerationParams) (time.Time, error) { - chat, err := q.db.GetChatByID(ctx, arg.ID) - if err != nil { - return time.Time{}, err - } - if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { - return time.Time{}, err - } - return q.db.StartChatSummaryGeneration(ctx, arg) -} - func (q *querier) SyncAgentChatsContextMCPResources(ctx context.Context, agentID uuid.UUID) ([]uuid.UUID, error) { // The push can update multiple chats bound to the agent, so authorize the // chat resource class. diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 7e0dfaf9d1299..5dd49f4306857 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -998,19 +998,6 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().GetActiveChatsByAgentID(gomock.Any(), agentID).Return([]database.Chat{chat}, nil).AnyTimes() check.Args(agentID).Asserts(chat, policy.ActionRead).Returns([]database.Chat{chat}) })) - s.Run("GetActiveChatSummaryGenerationsByOwnerID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { - chat := testutil.Fake(s.T(), faker, database.Chat{}) - arg := database.GetActiveChatSummaryGenerationsByOwnerIDParams{ - OwnerID: chat.OwnerID, - MaxAgeSeconds: 120, - } - rows := []database.GetActiveChatSummaryGenerationsByOwnerIDRow{{ - Chat: chat, - RemainingMs: 60_000, - }} - dbm.EXPECT().GetActiveChatSummaryGenerationsByOwnerID(gomock.Any(), arg).Return(rows, nil).AnyTimes() - check.Args(arg).Asserts(chat, policy.ActionRead).Returns(rows) - })) s.Run("SoftDeleteContextFileMessages", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() @@ -1981,27 +1968,6 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().UpdateChatLastTurnSummary(gomock.Any(), arg).Return(int64(1), nil).AnyTimes() check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(int64(1)) })) - s.Run("StartChatSummaryGeneration", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { - chat := testutil.Fake(s.T(), faker, database.Chat{}) - arg := database.StartChatSummaryGenerationParams{ - ID: chat.ID, - ExpectedHistoryVersion: chat.HistoryVersion, - } - startedAt := time.Now() - dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() - dbm.EXPECT().StartChatSummaryGeneration(gomock.Any(), arg).Return(startedAt, nil).AnyTimes() - check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(startedAt) - })) - s.Run("ClearChatSummaryGeneration", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { - chat := testutil.Fake(s.T(), faker, database.Chat{}) - arg := database.ClearChatSummaryGenerationParams{ - ID: chat.ID, - GenerationStartedAt: time.Now(), - } - dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() - dbm.EXPECT().ClearChatSummaryGeneration(gomock.Any(), arg).Return(int64(1), nil).AnyTimes() - check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(int64(1)) - })) s.Run("UpdateChatSummary", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) arg := database.UpdateChatSummaryParams{ diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 481f00ca7db6c..365899afbd278 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -320,14 +320,6 @@ func (m queryMetricsStore) ClearChatDiffStatusPR(ctx context.Context, arg databa return r0 } -func (m queryMetricsStore) ClearChatSummaryGeneration(ctx context.Context, arg database.ClearChatSummaryGenerationParams) (int64, error) { - start := time.Now() - r0, r1 := m.s.ClearChatSummaryGeneration(ctx, arg) - m.queryLatencies.WithLabelValues("ClearChatSummaryGeneration").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ClearChatSummaryGeneration").Inc() - return r0, r1 -} - func (m queryMetricsStore) CountAIBridgeSessions(ctx context.Context, arg database.CountAIBridgeSessionsParams) (int64, error) { start := time.Now() r0, r1 := m.s.CountAIBridgeSessions(ctx, arg) @@ -1312,14 +1304,6 @@ func (m queryMetricsStore) GetActiveAISeatCount(ctx context.Context) (int64, err return r0, r1 } -func (m queryMetricsStore) GetActiveChatSummaryGenerationsByOwnerID(ctx context.Context, arg database.GetActiveChatSummaryGenerationsByOwnerIDParams) ([]database.GetActiveChatSummaryGenerationsByOwnerIDRow, error) { - start := time.Now() - r0, r1 := m.s.GetActiveChatSummaryGenerationsByOwnerID(ctx, arg) - m.queryLatencies.WithLabelValues("GetActiveChatSummaryGenerationsByOwnerID").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetActiveChatSummaryGenerationsByOwnerID").Inc() - return r0, r1 -} - func (m queryMetricsStore) GetActiveChatsByAgentID(ctx context.Context, agentID uuid.UUID) ([]database.Chat, error) { start := time.Now() r0, r1 := m.s.GetActiveChatsByAgentID(ctx, agentID) @@ -5200,14 +5184,6 @@ func (m queryMetricsStore) SoftDeleteWorkspaceAgentsByWorkspaceID(ctx context.Co return r0 } -func (m queryMetricsStore) StartChatSummaryGeneration(ctx context.Context, id database.StartChatSummaryGenerationParams) (time.Time, error) { - start := time.Now() - r0, r1 := m.s.StartChatSummaryGeneration(ctx, id) - m.queryLatencies.WithLabelValues("StartChatSummaryGeneration").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "StartChatSummaryGeneration").Inc() - return r0, r1 -} - func (m queryMetricsStore) SyncAgentChatsContextMCPResources(ctx context.Context, agentID uuid.UUID) ([]uuid.UUID, error) { start := time.Now() r0, r1 := m.s.SyncAgentChatsContextMCPResources(ctx, agentID) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 11344f5ebbf12..f52286cf51170 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -436,21 +436,6 @@ func (mr *MockStoreMockRecorder) ClearChatDiffStatusPR(ctx, arg any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClearChatDiffStatusPR", reflect.TypeOf((*MockStore)(nil).ClearChatDiffStatusPR), ctx, arg) } -// ClearChatSummaryGeneration mocks base method. -func (m *MockStore) ClearChatSummaryGeneration(ctx context.Context, arg database.ClearChatSummaryGenerationParams) (int64, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ClearChatSummaryGeneration", ctx, arg) - ret0, _ := ret[0].(int64) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ClearChatSummaryGeneration indicates an expected call of ClearChatSummaryGeneration. -func (mr *MockStoreMockRecorder) ClearChatSummaryGeneration(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClearChatSummaryGeneration", reflect.TypeOf((*MockStore)(nil).ClearChatSummaryGeneration), ctx, arg) -} - // CountAIBridgeSessions mocks base method. func (m *MockStore) CountAIBridgeSessions(ctx context.Context, arg database.CountAIBridgeSessionsParams) (int64, error) { m.ctrl.T.Helper() @@ -2308,21 +2293,6 @@ func (mr *MockStoreMockRecorder) GetActiveAISeatCount(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveAISeatCount", reflect.TypeOf((*MockStore)(nil).GetActiveAISeatCount), ctx) } -// GetActiveChatSummaryGenerationsByOwnerID mocks base method. -func (m *MockStore) GetActiveChatSummaryGenerationsByOwnerID(ctx context.Context, arg database.GetActiveChatSummaryGenerationsByOwnerIDParams) ([]database.GetActiveChatSummaryGenerationsByOwnerIDRow, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetActiveChatSummaryGenerationsByOwnerID", ctx, arg) - ret0, _ := ret[0].([]database.GetActiveChatSummaryGenerationsByOwnerIDRow) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetActiveChatSummaryGenerationsByOwnerID indicates an expected call of GetActiveChatSummaryGenerationsByOwnerID. -func (mr *MockStoreMockRecorder) GetActiveChatSummaryGenerationsByOwnerID(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveChatSummaryGenerationsByOwnerID", reflect.TypeOf((*MockStore)(nil).GetActiveChatSummaryGenerationsByOwnerID), ctx, arg) -} - // GetActiveChatsByAgentID mocks base method. func (m *MockStore) GetActiveChatsByAgentID(ctx context.Context, agentID uuid.UUID) ([]database.Chat, error) { m.ctrl.T.Helper() @@ -9870,21 +9840,6 @@ func (mr *MockStoreMockRecorder) SoftDeleteWorkspaceAgentsByWorkspaceID(ctx, wor return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SoftDeleteWorkspaceAgentsByWorkspaceID", reflect.TypeOf((*MockStore)(nil).SoftDeleteWorkspaceAgentsByWorkspaceID), ctx, workspaceID) } -// StartChatSummaryGeneration mocks base method. -func (m *MockStore) StartChatSummaryGeneration(ctx context.Context, arg database.StartChatSummaryGenerationParams) (time.Time, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "StartChatSummaryGeneration", ctx, arg) - ret0, _ := ret[0].(time.Time) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// StartChatSummaryGeneration indicates an expected call of StartChatSummaryGeneration. -func (mr *MockStoreMockRecorder) StartChatSummaryGeneration(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartChatSummaryGeneration", reflect.TypeOf((*MockStore)(nil).StartChatSummaryGeneration), ctx, arg) -} - // SyncAgentChatsContextMCPResources mocks base method. func (m *MockStore) SyncAgentChatsContextMCPResources(ctx context.Context, agentID uuid.UUID) ([]uuid.UUID, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 7641434397bfc..6e93c540739ac 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -2142,11 +2142,6 @@ CREATE SEQUENCE chat_queued_messages_id_seq ALTER SEQUENCE chat_queued_messages_id_seq OWNED BY chat_queued_messages.id; -CREATE TABLE chat_summary_generations ( - chat_id uuid NOT NULL, - started_at timestamp with time zone DEFAULT now() NOT NULL -); - CREATE TABLE chat_usage_limit_config ( id bigint NOT NULL, singleton boolean DEFAULT true NOT NULL, @@ -4455,9 +4450,6 @@ ALTER TABLE ONLY chat_organization_model_overrides ALTER TABLE ONLY chat_queued_messages ADD CONSTRAINT chat_queued_messages_pkey PRIMARY KEY (id); -ALTER TABLE ONLY chat_summary_generations - ADD CONSTRAINT chat_summary_generations_pkey PRIMARY KEY (chat_id); - ALTER TABLE ONLY chat_usage_limit_config ADD CONSTRAINT chat_usage_limit_config_pkey PRIMARY KEY (id); @@ -5343,9 +5335,6 @@ ALTER TABLE ONLY chat_organization_model_overrides ALTER TABLE ONLY chat_queued_messages ADD CONSTRAINT chat_queued_messages_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; -ALTER TABLE ONLY chat_summary_generations - ADD CONSTRAINT chat_summary_generations_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; - ALTER TABLE ONLY chat_user_model_overrides ADD CONSTRAINT chat_user_model_overrides_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index 99f5e8502e0b4..251ce1aec5c36 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -33,7 +33,6 @@ const ( ForeignKeyChatOrganizationModelOverridesOrganizationID ForeignKeyConstraint = "chat_organization_model_overrides_organization_id_fkey" // ALTER TABLE ONLY chat_organization_model_overrides ADD CONSTRAINT chat_organization_model_overrides_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; ForeignKeyChatOrganizationModelOverridesOrganizationModelConfigFke ForeignKeyConstraint = "chat_organization_model_overrides_organization_model_config_fke" // ALTER TABLE ONLY chat_organization_model_overrides ADD CONSTRAINT chat_organization_model_overrides_organization_model_config_fke FOREIGN KEY (organization_id, model_config_id) REFERENCES chat_model_configs(organization_id, id); ForeignKeyChatQueuedMessagesChatID ForeignKeyConstraint = "chat_queued_messages_chat_id_fkey" // ALTER TABLE ONLY chat_queued_messages ADD CONSTRAINT chat_queued_messages_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; - ForeignKeyChatSummaryGenerationsChatID ForeignKeyConstraint = "chat_summary_generations_chat_id_fkey" // ALTER TABLE ONLY chat_summary_generations ADD CONSTRAINT chat_summary_generations_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; ForeignKeyChatUserModelOverridesOrganizationID ForeignKeyConstraint = "chat_user_model_overrides_organization_id_fkey" // ALTER TABLE ONLY chat_user_model_overrides ADD CONSTRAINT chat_user_model_overrides_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; ForeignKeyChatUserModelOverridesOrganizationModelConfig ForeignKeyConstraint = "chat_user_model_overrides_organization_model_config_fkey" // ALTER TABLE ONLY chat_user_model_overrides ADD CONSTRAINT chat_user_model_overrides_organization_model_config_fkey FOREIGN KEY (organization_id, model_config_id) REFERENCES chat_model_configs(organization_id, id); ForeignKeyChatUserModelOverridesUserID ForeignKeyConstraint = "chat_user_model_overrides_user_id_fkey" // ALTER TABLE ONLY chat_user_model_overrides ADD CONSTRAINT chat_user_model_overrides_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000591_chat_summary_generation_state.down.sql b/coderd/database/migrations/000591_chat_summary_generation_state.down.sql deleted file mode 100644 index 3d51ffbed2dc5..0000000000000 --- a/coderd/database/migrations/000591_chat_summary_generation_state.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE chat_summary_generations; diff --git a/coderd/database/migrations/000591_chat_summary_generation_state.up.sql b/coderd/database/migrations/000591_chat_summary_generation_state.up.sql deleted file mode 100644 index ef6895ebae887..0000000000000 --- a/coderd/database/migrations/000591_chat_summary_generation_state.up.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Active summary generation is tracked separately so chat reads stay stable --- while late watch subscribers can recover the transient loading state. -CREATE TABLE chat_summary_generations ( - chat_id UUID PRIMARY KEY REFERENCES chats(id) ON DELETE CASCADE, - started_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); diff --git a/coderd/database/migrations/testdata/fixtures/000591_chat_summary_generation_state.up.sql b/coderd/database/migrations/testdata/fixtures/000591_chat_summary_generation_state.up.sql deleted file mode 100644 index 1d4fd2959e43a..0000000000000 --- a/coderd/database/migrations/testdata/fixtures/000591_chat_summary_generation_state.up.sql +++ /dev/null @@ -1,10 +0,0 @@ -INSERT INTO chat_summary_generations ( - chat_id, - started_at -) -SELECT - chats.id, - '2024-01-01 00:00:00+00' -FROM chats -ORDER BY created_at, id -LIMIT 1; diff --git a/coderd/database/models.go b/coderd/database/models.go index d6bfd95cc0fca..b5a1340b353c4 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -5347,11 +5347,6 @@ type ChatQueuedMessage struct { ReasoningEffort NullChatReasoningEffort `db:"reasoning_effort" json:"reasoning_effort"` } -type ChatSummaryGeneration struct { - ChatID uuid.UUID `db:"chat_id" json:"chat_id"` - StartedAt time.Time `db:"started_at" json:"started_at"` -} - type ChatTable struct { ID uuid.UUID `db:"id" json:"id"` OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index df96c60d21aab..88e86e97304ac 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -94,7 +94,6 @@ type sqlcQuerier interface { CleanTailnetTunnels(ctx context.Context) error CleanupDeletedMCPServerIDsFromChats(ctx context.Context) error ClearChatDiffStatusPR(ctx context.Context, arg ClearChatDiffStatusPRParams) error - ClearChatSummaryGeneration(ctx context.Context, arg ClearChatSummaryGenerationParams) (int64, error) CountAIBridgeSessions(ctx context.Context, arg CountAIBridgeSessionsParams) (int64, error) CountAuditLogs(ctx context.Context, arg CountAuditLogsParams) (int64, error) // Excluding the candidate keeps ownership takeover capacity-neutral. @@ -374,7 +373,6 @@ type sqlcQuerier interface { GetAPIKeysByUserID(ctx context.Context, arg GetAPIKeysByUserIDParams) ([]APIKey, error) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error) GetActiveAISeatCount(ctx context.Context) (int64, error) - GetActiveChatSummaryGenerationsByOwnerID(ctx context.Context, arg GetActiveChatSummaryGenerationsByOwnerIDParams) ([]GetActiveChatSummaryGenerationsByOwnerIDRow, error) GetActiveChatsByAgentID(ctx context.Context, agentID uuid.UUID) ([]Chat, error) GetActivePresetPrebuildSchedules(ctx context.Context) ([]TemplateVersionPresetPrebuildSchedule, error) GetActiveUserCount(ctx context.Context, includeSystem bool) (int64, error) @@ -1415,8 +1413,6 @@ type sqlcQuerier interface { // Agent context rows are hard-deleted for the same reason as in // SoftDeletePriorWorkspaceAgents. SoftDeleteWorkspaceAgentsByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) error - // Clients order generation identities at millisecond precision. - StartChatSummaryGeneration(ctx context.Context, arg StartChatSummaryGenerationParams) (time.Time, error) // MCP resources bypass context drift and are live-synced on each push. // Changed chats are locked in ID order so concurrent clear-then-copy re-pins // cannot interleave with the replacement. @@ -1531,8 +1527,7 @@ type sqlcQuerier interface { UpdateChatRetryState(ctx context.Context, arg UpdateChatRetryStateParams) (Chat, error) UpdateChatStatus(ctx context.Context, arg UpdateChatStatusParams) (Chat, error) // The history_version fence lets background summary writes ignore worker-only - // updates while losing to newer message history. Root summary workers atomically - // delete their generation marker so storage and replay state cannot diverge. + // updates while losing to newer message history. UpdateChatSummary(ctx context.Context, arg UpdateChatSummaryParams) (int64, error) UpdateChatTitleByID(ctx context.Context, arg UpdateChatTitleByIDParams) (Chat, error) UpdateChatWorkspaceBinding(ctx context.Context, arg UpdateChatWorkspaceBindingParams) (Chat, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index a656948fc2a0f..6c0e5344ef306 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -16271,66 +16271,10 @@ func TestUpdateChatSummary(t *testing.T) { require.False(t, chat.Summary.Valid) require.False(t, chat.SummaryGeneratedAt.Valid) - generationStartedAt, err := db.StartChatSummaryGeneration(ctx, database.StartChatSummaryGenerationParams{ - ID: chat.ID, - ExpectedHistoryVersion: chat.HistoryVersion, - }) - require.NoError(t, err) - - activeGenerations, err := db.GetActiveChatSummaryGenerationsByOwnerID(ctx, database.GetActiveChatSummaryGenerationsByOwnerIDParams{ - OwnerID: owner.ID, - MaxAgeSeconds: 60, - }) - require.NoError(t, err) - require.Len(t, activeGenerations, 1) - require.Equal(t, chat.ID, activeGenerations[0].Chat.ID) - require.True(t, generationStartedAt.Equal(activeGenerations[0].GenerationStartedAt)) - require.Positive(t, activeGenerations[0].RemainingMs) - require.LessOrEqual(t, activeGenerations[0].RemainingMs, int64(60_000)) - - previousGenerationStartedAt := generationStartedAt - generationStartedAt, err = db.StartChatSummaryGeneration(ctx, database.StartChatSummaryGenerationParams{ + affected, err := db.UpdateChatSummary(ctx, database.UpdateChatSummaryParams{ ID: chat.ID, ExpectedHistoryVersion: chat.HistoryVersion, - }) - require.NoError(t, err) - require.GreaterOrEqual(t, generationStartedAt.Sub(previousGenerationStartedAt), time.Millisecond) - - _, err = db.StartChatSummaryGeneration(ctx, database.StartChatSummaryGenerationParams{ - ID: chat.ID, - ExpectedHistoryVersion: chat.HistoryVersion - 1, - }) - require.ErrorIs(t, err, sql.ErrNoRows) - - activeGenerations, err = db.GetActiveChatSummaryGenerationsByOwnerID(ctx, database.GetActiveChatSummaryGenerationsByOwnerIDParams{ - OwnerID: owner.ID, - MaxAgeSeconds: 60, - }) - require.NoError(t, err) - require.Len(t, activeGenerations, 1) - require.True(t, generationStartedAt.Equal(activeGenerations[0].GenerationStartedAt)) - - expiredGenerations, err := db.GetActiveChatSummaryGenerationsByOwnerID(ctx, database.GetActiveChatSummaryGenerationsByOwnerIDParams{ - OwnerID: owner.ID, - MaxAgeSeconds: 0, - }) - require.NoError(t, err) - require.Empty(t, expiredGenerations) - - affected, err := db.UpdateChatSummary(ctx, database.UpdateChatSummaryParams{ - ID: chat.ID, - ExpectedHistoryVersion: chat.HistoryVersion, - ExpectedGenerationStartedAt: sql.NullTime{Time: generationStartedAt.Add(-time.Second), Valid: true}, - Summary: sql.NullString{String: "stale generation", Valid: true}, - }) - require.NoError(t, err) - require.Zero(t, affected) - - affected, err = db.UpdateChatSummary(ctx, database.UpdateChatSummaryParams{ - ID: chat.ID, - ExpectedHistoryVersion: chat.HistoryVersion, - ExpectedGenerationStartedAt: sql.NullTime{Time: generationStartedAt, Valid: true}, - Summary: sql.NullString{String: "Implemented the whole-chat summary feature.", Valid: true}, + Summary: sql.NullString{String: "Implemented the whole-chat summary feature.", Valid: true}, }) require.NoError(t, err) require.EqualValues(t, 1, affected) @@ -16341,20 +16285,6 @@ func TestUpdateChatSummary(t *testing.T) { require.True(t, fetched.SummaryGeneratedAt.Valid) require.Equal(t, chat.UpdatedAt, fetched.UpdatedAt) - activeGenerations, err = db.GetActiveChatSummaryGenerationsByOwnerID(ctx, database.GetActiveChatSummaryGenerationsByOwnerIDParams{ - OwnerID: owner.ID, - MaxAgeSeconds: 60, - }) - require.NoError(t, err) - require.Empty(t, activeGenerations) - - cleared, err := db.ClearChatSummaryGeneration(ctx, database.ClearChatSummaryGenerationParams{ - ID: chat.ID, - GenerationStartedAt: generationStartedAt, - }) - require.NoError(t, err) - require.Zero(t, cleared) - affected, err = db.UpdateChatSummary(ctx, database.UpdateChatSummaryParams{ ID: chat.ID, ExpectedHistoryVersion: chat.HistoryVersion, @@ -16411,86 +16341,6 @@ func TestUpdateChatSummary(t *testing.T) { require.NoError(t, err) require.Equal(t, sql.NullString{String: "Fresh whole-chat summary.", Valid: true}, fetched.Summary) require.NotEqual(t, chat.HistoryVersion, fetched.HistoryVersion) - - // A concurrent history update must win before the generation marker is - // deleted, otherwise reconnect replay loses track of the active worker. - generationStartedAt, err = db.StartChatSummaryGeneration(ctx, database.StartChatSummaryGenerationParams{ - ID: chat.ID, - ExpectedHistoryVersion: fetched.HistoryVersion, - }) - require.NoError(t, err) - - historyTx, err := sqlDB.BeginTx(ctx, nil) - require.NoError(t, err) - historyTxCommitted := false - t.Cleanup(func() { - if !historyTxCommitted { - _ = historyTx.Rollback() - } - }) - var lockedChatID uuid.UUID - err = historyTx.QueryRowContext(ctx, ` -SELECT id -FROM chats -WHERE id = $1 -FOR UPDATE -`, chat.ID).Scan(&lockedChatID) - require.NoError(t, err) - require.Equal(t, chat.ID, lockedChatID) - - type summaryUpdateResult struct { - affected int64 - err error - } - updateResult := make(chan summaryUpdateResult, 1) - go func() { - affected, err := db.UpdateChatSummary(ctx, database.UpdateChatSummaryParams{ - ID: chat.ID, - ExpectedHistoryVersion: fetched.HistoryVersion, - ExpectedGenerationStartedAt: sql.NullTime{Time: generationStartedAt, Valid: true}, - Summary: sql.NullString{String: "Raced whole-chat summary.", Valid: true}, - }) - updateResult <- summaryUpdateResult{affected: affected, err: err} - }() - - var summaryLockWaits int - testutil.Eventually(ctx, t, func(ctx context.Context) bool { - err := historyTx.QueryRowContext(ctx, ` -SELECT COUNT(*) -FROM pg_stat_activity -WHERE datname = current_database() - AND pid <> pg_backend_pid() - AND query LIKE '%-- name: UpdateChatSummary%' - AND wait_event_type = 'Lock' -`).Scan(&summaryLockWaits) - return err == nil && summaryLockWaits == 1 - }, testutil.IntervalFast, "wait for summary update to reach the chat row lock") - require.NoError(t, ctx.Err(), "waiting for summary update") - - _, err = historyTx.ExecContext(ctx, ` -UPDATE chats -SET history_version = history_version + 1 -WHERE id = $1 -`, chat.ID) - require.NoError(t, err) - require.NoError(t, historyTx.Commit()) - historyTxCommitted = true - - select { - case result := <-updateResult: - require.NoError(t, result.err) - require.Zero(t, result.affected) - case <-ctx.Done(): - require.Failf(t, "summary update did not finish", "context ended: %v", ctx.Err()) - } - - activeGenerations, err = db.GetActiveChatSummaryGenerationsByOwnerID(ctx, database.GetActiveChatSummaryGenerationsByOwnerIDParams{ - OwnerID: owner.ID, - MaxAgeSeconds: 60, - }) - require.NoError(t, err) - require.Len(t, activeGenerations, 1) - require.True(t, generationStartedAt.Equal(activeGenerations[0].GenerationStartedAt)) } func TestUpdateChatWorkspaceBindingNoOp(t *testing.T) { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index cd1ba2bb3527b..ccbd4a5af2a27 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -7596,26 +7596,6 @@ func (q *sqlQuerier) ClearChatDiffStatusPR(ctx context.Context, arg ClearChatDif return err } -const clearChatSummaryGeneration = `-- name: ClearChatSummaryGeneration :execrows -DELETE FROM chat_summary_generations -WHERE - chat_id = $1::uuid - AND started_at = $2::timestamptz -` - -type ClearChatSummaryGenerationParams struct { - ID uuid.UUID `db:"id" json:"id"` - GenerationStartedAt time.Time `db:"generation_started_at" json:"generation_started_at"` -} - -func (q *sqlQuerier) ClearChatSummaryGeneration(ctx context.Context, arg ClearChatSummaryGenerationParams) (int64, error) { - result, err := q.db.ExecContext(ctx, clearChatSummaryGeneration, arg.ID, arg.GenerationStartedAt) - if err != nil { - return 0, err - } - return result.RowsAffected() -} - const countChatCapacityActiveByPool = `-- name: CountChatCapacityActiveByPool :one SELECT COUNT(*) FILTER (WHERE c.parent_chat_id IS NULL)::bigint AS active_root_count, @@ -7820,113 +7800,6 @@ func (q *sqlQuerier) DeleteStaleChatHeartbeats(ctx context.Context, staleSeconds return result.RowsAffected() } -const getActiveChatSummaryGenerationsByOwnerID = `-- name: GetActiveChatSummaryGenerationsByOwnerID :many -WITH params AS ( - SELECT $2::int AS max_age_seconds -) -SELECT - c.id, c.owner_id, c.workspace_id, c.title, c.status, c.worker_id, c.started_at, c.heartbeat_at, c.created_at, c.updated_at, c.parent_chat_id, c.root_chat_id, c.last_model_config_id, c.last_reasoning_effort, c.archived, c.last_error, c.mode, c.mcp_server_ids, c.labels, c.build_id, c.agent_id, c.pin_order, c.last_read_message_id, c.dynamic_tools, c.organization_id, c.plan_mode, c.client_type, c.last_turn_summary, c.summary, c.summary_generated_at, c.snapshot_version, c.history_version, c.queue_version, c.generation_attempt, c.retry_state, c.retry_state_version, c.runner_id, c.requires_action_deadline_at, c.user_acl, c.group_acl, c.owner_username, c.owner_name, c.context_aggregate_hash, c.context_dirty_since, c.context_dirty_resources, c.context_error, c.compaction_requested_at, - ( - GREATEST( - 0, - params.max_age_seconds::bigint - FLOOR(EXTRACT(EPOCH FROM NOW() - g.started_at))::bigint - ) * 1000 - )::bigint AS remaining_ms, - g.started_at AS generation_started_at -FROM chat_summary_generations g -JOIN chats_expanded c ON c.id = g.chat_id -CROSS JOIN params -WHERE - c.owner_id = $1::uuid - AND c.parent_chat_id IS NULL - AND g.started_at > NOW() - (INTERVAL '1 second' * params.max_age_seconds) -ORDER BY g.started_at -` - -type GetActiveChatSummaryGenerationsByOwnerIDParams struct { - OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` - MaxAgeSeconds int32 `db:"max_age_seconds" json:"max_age_seconds"` -} - -type GetActiveChatSummaryGenerationsByOwnerIDRow struct { - Chat Chat `db:"chat" json:"chat"` - RemainingMs int64 `db:"remaining_ms" json:"remaining_ms"` - GenerationStartedAt time.Time `db:"generation_started_at" json:"generation_started_at"` -} - -func (q *sqlQuerier) GetActiveChatSummaryGenerationsByOwnerID(ctx context.Context, arg GetActiveChatSummaryGenerationsByOwnerIDParams) ([]GetActiveChatSummaryGenerationsByOwnerIDRow, error) { - rows, err := q.db.QueryContext(ctx, getActiveChatSummaryGenerationsByOwnerID, arg.OwnerID, arg.MaxAgeSeconds) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetActiveChatSummaryGenerationsByOwnerIDRow - for rows.Next() { - var i GetActiveChatSummaryGenerationsByOwnerIDRow - if err := rows.Scan( - &i.Chat.ID, - &i.Chat.OwnerID, - &i.Chat.WorkspaceID, - &i.Chat.Title, - &i.Chat.Status, - &i.Chat.WorkerID, - &i.Chat.StartedAt, - &i.Chat.HeartbeatAt, - &i.Chat.CreatedAt, - &i.Chat.UpdatedAt, - &i.Chat.ParentChatID, - &i.Chat.RootChatID, - &i.Chat.LastModelConfigID, - &i.Chat.LastReasoningEffort, - &i.Chat.Archived, - &i.Chat.LastError, - &i.Chat.Mode, - pq.Array(&i.Chat.MCPServerIDs), - &i.Chat.Labels, - &i.Chat.BuildID, - &i.Chat.AgentID, - &i.Chat.PinOrder, - &i.Chat.LastReadMessageID, - &i.Chat.DynamicTools, - &i.Chat.OrganizationID, - &i.Chat.PlanMode, - &i.Chat.ClientType, - &i.Chat.LastTurnSummary, - &i.Chat.Summary, - &i.Chat.SummaryGeneratedAt, - &i.Chat.SnapshotVersion, - &i.Chat.HistoryVersion, - &i.Chat.QueueVersion, - &i.Chat.GenerationAttempt, - &i.Chat.RetryState, - &i.Chat.RetryStateVersion, - &i.Chat.RunnerID, - &i.Chat.RequiresActionDeadlineAt, - &i.Chat.UserACL, - &i.Chat.GroupACL, - &i.Chat.OwnerUsername, - &i.Chat.OwnerName, - &i.Chat.ContextAggregateHash, - &i.Chat.ContextDirtySince, - &i.Chat.ContextDirtyResources, - &i.Chat.ContextError, - &i.Chat.CompactionRequestedAt, - &i.RemainingMs, - &i.GenerationStartedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const getActiveChatsByAgentID = `-- name: GetActiveChatsByAgentID :many 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, summary, summary_generated_at, 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, compaction_requested_at FROM chats_expanded @@ -11746,38 +11619,6 @@ func (q *sqlQuerier) SoftDeleteContextFileMessages(ctx context.Context, chatID u return err } -const startChatSummaryGeneration = `-- name: StartChatSummaryGeneration :one -WITH locked_chat AS ( - SELECT id - FROM chats - WHERE - id = $1::uuid - AND history_version = $2::bigint - FOR UPDATE -) -INSERT INTO chat_summary_generations (chat_id) -SELECT id FROM locked_chat -ON CONFLICT (chat_id) DO UPDATE -SET started_at = GREATEST( - NOW(), - chat_summary_generations.started_at + INTERVAL '1 millisecond' -) -RETURNING started_at -` - -type StartChatSummaryGenerationParams struct { - ID uuid.UUID `db:"id" json:"id"` - ExpectedHistoryVersion int64 `db:"expected_history_version" json:"expected_history_version"` -} - -// Clients order generation identities at millisecond precision. -func (q *sqlQuerier) StartChatSummaryGeneration(ctx context.Context, arg StartChatSummaryGenerationParams) (time.Time, error) { - row := q.db.QueryRowContext(ctx, startChatSummaryGeneration, arg.ID, arg.ExpectedHistoryVersion) - var started_at time.Time - err := row.Scan(&started_at) - return started_at, err -} - const syncAgentChatsContextMCPResources = `-- name: SyncAgentChatsContextMCPResources :many WITH agent_mcp AS ( SELECT source, body_kind, body, content_hash, size_bytes, status, error, source_path @@ -13473,52 +13314,25 @@ func (q *sqlQuerier) UpdateChatStatus(ctx context.Context, arg UpdateChatStatusP } const updateChatSummary = `-- name: UpdateChatSummary :execrows -WITH locked_chat AS ( - SELECT id - FROM chats - WHERE - id = $3::uuid - AND history_version = $4::bigint - FOR UPDATE -), -cleared_generation AS ( - DELETE FROM chat_summary_generations g - USING locked_chat - WHERE - g.chat_id = locked_chat.id - AND g.started_at = $2::timestamptz - RETURNING g.chat_id -) UPDATE chats SET summary = $1::text, summary_generated_at = NOW() -FROM locked_chat WHERE - chats.id = locked_chat.id - AND ( - $2::timestamptz IS NULL - OR EXISTS (SELECT 1 FROM cleared_generation) - ) + id = $2::uuid + AND history_version = $3::bigint ` type UpdateChatSummaryParams struct { - Summary sql.NullString `db:"summary" json:"summary"` - ExpectedGenerationStartedAt sql.NullTime `db:"expected_generation_started_at" json:"expected_generation_started_at"` - ID uuid.UUID `db:"id" json:"id"` - ExpectedHistoryVersion int64 `db:"expected_history_version" json:"expected_history_version"` + Summary sql.NullString `db:"summary" json:"summary"` + ID uuid.UUID `db:"id" json:"id"` + ExpectedHistoryVersion int64 `db:"expected_history_version" json:"expected_history_version"` } // The history_version fence lets background summary writes ignore worker-only -// updates while losing to newer message history. Root summary workers atomically -// delete their generation marker so storage and replay state cannot diverge. +// updates while losing to newer message history. func (q *sqlQuerier) UpdateChatSummary(ctx context.Context, arg UpdateChatSummaryParams) (int64, error) { - result, err := q.db.ExecContext(ctx, updateChatSummary, - arg.Summary, - arg.ExpectedGenerationStartedAt, - arg.ID, - arg.ExpectedHistoryVersion, - ) + result, err := q.db.ExecContext(ctx, updateChatSummary, arg.Summary, arg.ID, arg.ExpectedHistoryVersion) if err != nil { return 0, err } diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index 0e9e93f8c068e..616936f3c661f 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -1496,84 +1496,16 @@ WHERE id = @id::uuid AND history_version = @expected_history_version::bigint; --- name: StartChatSummaryGeneration :one -WITH locked_chat AS ( - SELECT id - FROM chats - WHERE - id = @id::uuid - AND history_version = @expected_history_version::bigint - FOR UPDATE -) -INSERT INTO chat_summary_generations (chat_id) -SELECT id FROM locked_chat -ON CONFLICT (chat_id) DO UPDATE --- Clients order generation identities at millisecond precision. -SET started_at = GREATEST( - NOW(), - chat_summary_generations.started_at + INTERVAL '1 millisecond' -) -RETURNING started_at; - --- name: ClearChatSummaryGeneration :execrows -DELETE FROM chat_summary_generations -WHERE - chat_id = @id::uuid - AND started_at = sqlc.arg('generation_started_at')::timestamptz; - --- name: GetActiveChatSummaryGenerationsByOwnerID :many -WITH params AS ( - SELECT @max_age_seconds::int AS max_age_seconds -) -SELECT - sqlc.embed(c), - ( - GREATEST( - 0, - params.max_age_seconds::bigint - FLOOR(EXTRACT(EPOCH FROM NOW() - g.started_at))::bigint - ) * 1000 - )::bigint AS remaining_ms, - g.started_at AS generation_started_at -FROM chat_summary_generations g -JOIN chats_expanded c ON c.id = g.chat_id -CROSS JOIN params -WHERE - c.owner_id = @owner_id::uuid - AND c.parent_chat_id IS NULL - AND g.started_at > NOW() - (INTERVAL '1 second' * params.max_age_seconds) -ORDER BY g.started_at; - -- name: UpdateChatSummary :execrows -- The history_version fence lets background summary writes ignore worker-only --- updates while losing to newer message history. Root summary workers atomically --- delete their generation marker so storage and replay state cannot diverge. -WITH locked_chat AS ( - SELECT id - FROM chats - WHERE - id = @id::uuid - AND history_version = @expected_history_version::bigint - FOR UPDATE -), -cleared_generation AS ( - DELETE FROM chat_summary_generations g - USING locked_chat - WHERE - g.chat_id = locked_chat.id - AND g.started_at = sqlc.narg('expected_generation_started_at')::timestamptz - RETURNING g.chat_id -) +-- updates while losing to newer message history. UPDATE chats SET summary = sqlc.narg('summary')::text, summary_generated_at = NOW() -FROM locked_chat WHERE - chats.id = locked_chat.id - AND ( - sqlc.narg('expected_generation_started_at')::timestamptz IS NULL - OR EXISTS (SELECT 1 FROM cleared_generation) - ); + id = @id::uuid + AND history_version = @expected_history_version::bigint; -- name: UpdateChatMCPServerIDs :one WITH updated_chat AS ( diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 80adca6413313..9d2390a6468ba 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -35,7 +35,6 @@ const ( UniqueChatOrganizationModelOverridesOrganizationIDContextKey UniqueConstraint = "chat_organization_model_overrides_organization_id_context_key" // ALTER TABLE ONLY chat_organization_model_overrides ADD CONSTRAINT chat_organization_model_overrides_organization_id_context_key UNIQUE (organization_id, context); UniqueChatOrganizationModelOverridesPkey UniqueConstraint = "chat_organization_model_overrides_pkey" // ALTER TABLE ONLY chat_organization_model_overrides ADD CONSTRAINT chat_organization_model_overrides_pkey PRIMARY KEY (id); UniqueChatQueuedMessagesPkey UniqueConstraint = "chat_queued_messages_pkey" // ALTER TABLE ONLY chat_queued_messages ADD CONSTRAINT chat_queued_messages_pkey PRIMARY KEY (id); - UniqueChatSummaryGenerationsPkey UniqueConstraint = "chat_summary_generations_pkey" // ALTER TABLE ONLY chat_summary_generations ADD CONSTRAINT chat_summary_generations_pkey PRIMARY KEY (chat_id); UniqueChatUsageLimitConfigPkey UniqueConstraint = "chat_usage_limit_config_pkey" // ALTER TABLE ONLY chat_usage_limit_config ADD CONSTRAINT chat_usage_limit_config_pkey PRIMARY KEY (id); UniqueChatUsageLimitConfigSingletonKey UniqueConstraint = "chat_usage_limit_config_singleton_key" // ALTER TABLE ONLY chat_usage_limit_config ADD CONSTRAINT chat_usage_limit_config_singleton_key UNIQUE (singleton); UniqueChatUserModelOverridesPkey UniqueConstraint = "chat_user_model_overrides_pkey" // ALTER TABLE ONLY chat_user_model_overrides ADD CONSTRAINT chat_user_model_overrides_pkey PRIMARY KEY (id); diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index b8f4bc912b45d..9f21f1c0adbac 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -274,8 +274,9 @@ func (api *API) watchChats(rw http.ResponseWriter, r *http.Request) { if encoder == nil { return } - // Replays finish before encoderReady closes. After that, - // only this serial pubsub delivery goroutine writes. + // The encoder is only written from the pubsub delivery + // goroutine, which processes messages serially. Do not + // add a second write path without synchronization. if err := encoder.Encode(payload); err != nil { logger.Debug(cbCtx, "failed to send chat watch event", slog.Error(err)) cancel() @@ -294,23 +295,6 @@ func (api *API) watchChats(rw http.ResponseWriter, r *http.Request) { } defer cancelSubscribe() - activeSummaryGenerations, err := api.Database.GetActiveChatSummaryGenerationsByOwnerID( - ctx, - database.GetActiveChatSummaryGenerationsByOwnerIDParams{ - OwnerID: apiKey.UserID, - MaxAgeSeconds: int32(codersdk.ChatSummaryGenerationTimeout / time.Second), - }, - ) - if err != nil { - close(encoderReady) - logger.Error(ctx, "failed to load active chat summary generations", slog.Error(err)) - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to load active chat summary generations.", - Detail: err.Error(), - }) - return - } - conn, err := websocket.Accept(rw, r, nil) if err != nil { close(encoderReady) @@ -329,22 +313,6 @@ func (api *API) watchChats(rw http.ResponseWriter, r *http.Request) { ctx = api.wsWatcher.Watch(ctx, logger, conn) encoder = json.NewEncoder(wsNetConn) - for _, generation := range activeSummaryGenerations { - generationStartedAt := generation.GenerationStartedAt - remainingMs := generation.RemainingMs - if err := encoder.Encode(codersdk.ChatWatchEvent{ - Kind: codersdk.ChatWatchEventKindChatSummaryGenerating, - Chat: db2sdk.Chat(generation.Chat, nil, nil), - ChatSummaryGenerationStartedAt: &generationStartedAt, - ChatSummaryGenerationRemainingMS: &remainingMs, - }); err != nil { - encoder = nil - close(encoderReady) - logger.Debug(ctx, "failed to replay chat summary generation", - slog.F("chat_id", generation.Chat.ID), slog.Error(err)) - return - } - } close(encoderReady) <-ctx.Done() diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 971abda29af38..4644dab67f5d7 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -3133,50 +3133,6 @@ func TestWatchChats(t *testing.T) { } } }) - t.Run("ReplaysActiveSummaryGeneration", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ - DeploymentValues: coderdtest.DeploymentValues(t), - }) - client := codersdk.NewExperimentalClient(rawClient) - user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModel(t, client) - chat := dbgen.Chat(t, api.Database, database.Chat{ - OrganizationID: user.OrganizationID, - OwnerID: user.UserID, - LastModelConfigID: modelConfig.ID, - }) - - generationStartedAt, err := api.Database.StartChatSummaryGeneration( - dbauthz.AsChatd(ctx), - database.StartChatSummaryGenerationParams{ - ID: chat.ID, - ExpectedHistoryVersion: chat.HistoryVersion, - }, - ) - require.NoError(t, err) - - conn, err := client.Dial(ctx, "/api/experimental/chats/watch", nil) - require.NoError(t, err) - defer conn.Close(websocket.StatusNormalClosure, "done") - - var payload codersdk.ChatWatchEvent - require.NoError(t, wsjson.Read(ctx, conn, &payload)) - require.Equal(t, codersdk.ChatWatchEventKindChatSummaryGenerating, payload.Kind) - require.Equal(t, chat.ID, payload.Chat.ID) - require.NotNil(t, payload.ChatSummaryGenerationStartedAt) - require.True(t, generationStartedAt.Equal(*payload.ChatSummaryGenerationStartedAt)) - require.NotNil(t, payload.ChatSummaryGenerationRemainingMS) - require.Positive(t, *payload.ChatSummaryGenerationRemainingMS) - require.LessOrEqual( - t, - *payload.ChatSummaryGenerationRemainingMS, - codersdk.ChatSummaryGenerationTimeout.Milliseconds(), - ) - }) - t.Run("CreatedEventIncludesAllChatFields", func(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index 3239be00f4393..f804c15ae05ac 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -584,8 +584,6 @@ No other input states are supported: generating chats and chats with queued mess ## Pubsub - - The chat worker and the stream loop need real-time notifications when the chat state changes to ensure they are responsive. To achieve this, we use pubsub. As with the transitions section, I don't recommend reading the rest of this section thoroughly at first. Give it a cursory look, and treat it as a reference that you can return to later when you're analyzing the `GET /api/experimental/chats/{chat}/stream` endpoint or the chat worker. @@ -623,8 +621,6 @@ There are 2 notification channels: # Chat worker - - A chat worker lives inside every coderd replica. It acquires chats, calls the LLM API, executes tools, handles interrupts and tool-result waits, and commits completed outcomes through the core state machine. The chat worker is responsible for: diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index db02fa86e86f4..7951b36a14f2f 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -3226,7 +3226,13 @@ func chatWatchEventSDKChat(chat database.Chat, diffStatus *codersdk.ChatDiffStat return sdkChat } -func (p *Server) publishChatWatchEvent(chat database.Chat, event codersdk.ChatWatchEvent) { +// publishChatPubsubEvent broadcasts a chat lifecycle event via PostgreSQL +// pubsub so that all replicas can push updates to watching clients. +func (p *Server) publishChatPubsubEvent(chat database.Chat, kind codersdk.ChatWatchEventKind, diffStatus *codersdk.ChatDiffStatus) { + event := codersdk.ChatWatchEvent{ + Kind: kind, + Chat: chatWatchEventSDKChat(chat, diffStatus), + } payload, err := json.Marshal(event) if err != nil { p.logger.Error(context.Background(), "failed to marshal chat pubsub event", @@ -3238,33 +3244,12 @@ func (p *Server) publishChatWatchEvent(chat database.Chat, event codersdk.ChatWa if err := p.pubsub.Publish(coderdpubsub.ChatWatchEventChannel(chat.OwnerID), payload); err != nil { p.logger.Error(context.Background(), "failed to publish chat pubsub event", slog.F("chat_id", chat.ID), - slog.F("kind", event.Kind), + slog.F("kind", kind), slog.Error(err), ) } } -// publishChatPubsubEvent broadcasts a chat lifecycle event via PostgreSQL -// pubsub so that all replicas can push updates to watching clients. -func (p *Server) publishChatPubsubEvent(chat database.Chat, kind codersdk.ChatWatchEventKind, diffStatus *codersdk.ChatDiffStatus) { - p.publishChatWatchEvent(chat, codersdk.ChatWatchEvent{ - Kind: kind, - Chat: chatWatchEventSDKChat(chat, diffStatus), - }) -} - -func (p *Server) publishChatSummaryGenerationEvent( - chat database.Chat, - kind codersdk.ChatWatchEventKind, - generationStartedAt time.Time, -) { - p.publishChatWatchEvent(chat, codersdk.ChatWatchEvent{ - Kind: kind, - Chat: chatWatchEventSDKChat(chat, nil), - ChatSummaryGenerationStartedAt: &generationStartedAt, - }) -} - // ChatQueuedForCapacity reports whether the chat is waiting for a // concurrent-agent capacity slot. Uncapped deployments always return false. func (p *Server) ChatQueuedForCapacity(ctx context.Context, chat database.Chat) (bool, error) { @@ -4668,7 +4653,7 @@ const ( // New completed user turns before the summary is regenerated (since the last summary). summaryStaleTurnThreshold = 3 summaryMinTranscriptRunes = 200 - chatSummaryWorkTimeout = codersdk.ChatSummaryGenerationTimeout + chatSummaryWorkTimeout = 120 * time.Second chatSummaryGenerateTimeout = 60 * time.Second chatSummaryWriteTimeout = 5 * time.Second @@ -4761,28 +4746,6 @@ func (p *Server) generateAndStoreChatSummary( return } - generationStartedAt, err := p.db.StartChatSummaryGeneration(ctx, database.StartChatSummaryGenerationParams{ - ID: chat.ID, - ExpectedHistoryVersion: chat.HistoryVersion, - }) - if err != nil { - logger.Debug(ctx, "failed to mark chat summary generation", - slog.F("chat_id", chat.ID), slog.Error(err)) - return - } - summaryStored := false - defer func() { - if !summaryStored { - p.failChatSummaryGeneration(ctx, logger, chat, generationStartedAt) - } - }() - - p.publishChatSummaryGenerationEvent( - chat, - codersdk.ChatWatchEventKindChatSummaryGenerating, - generationStartedAt, - ) - summaryCtx, cancelGen := context.WithTimeout(ctx, chatSummaryGenerateTimeout) defer cancelGen() summary, _, genErr := generateChatSummary(summaryCtx, resolved.model.LanguageModel(), summaryObjectCall(resolved), transcript) @@ -4793,10 +4756,7 @@ func (p *Server) generateAndStoreChatSummary( return } - summaryStored = p.updateChatSummary(ctx, logger, chat, chat.HistoryVersion, sql.NullTime{ - Time: generationStartedAt, - Valid: true, - }, summary) + p.updateChatSummary(ctx, logger, chat, chat.HistoryVersion, summary) } func (p *Server) resolveChatSummaryModel( @@ -4850,42 +4810,6 @@ func countCompletedTurnsSince(messages []database.ChatMessage, after time.Time) return count } -func (p *Server) clearChatSummaryGeneration( - ctx context.Context, - logger slog.Logger, - chatID uuid.UUID, - generationStartedAt time.Time, -) bool { - ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), chatSummaryWriteTimeout) - defer cancel() - - affected, err := p.db.ClearChatSummaryGeneration(ctx, database.ClearChatSummaryGenerationParams{ - ID: chatID, - GenerationStartedAt: generationStartedAt, - }) - if err != nil { - logger.Warn(ctx, "failed to clear chat summary generation", - slog.F("chat_id", chatID), slog.Error(err)) - return false - } - return affected > 0 -} - -func (p *Server) failChatSummaryGeneration( - ctx context.Context, - logger slog.Logger, - chat database.Chat, - generationStartedAt time.Time, -) { - if p.clearChatSummaryGeneration(ctx, logger, chat.ID, generationStartedAt) { - p.publishChatSummaryGenerationEvent( - chat, - codersdk.ChatWatchEventKindChatSummaryFailed, - generationStartedAt, - ) - } -} - // updateChatSummary persists the whole-chat summary. Best-effort background // write (pass a detached context); a blank summary is a no-op, never clearing // an existing one. @@ -4894,12 +4818,11 @@ func (p *Server) updateChatSummary( logger slog.Logger, chat database.Chat, expectedHistoryVersion int64, - expectedGenerationStartedAt sql.NullTime, summary string, -) bool { +) { summary = strings.TrimSpace(summary) if summary == "" { - return false + return } sqlSummary := sql.NullString{String: summary, Valid: true} @@ -4907,15 +4830,14 @@ func (p *Server) updateChatSummary( defer cancel() affected, err := p.db.UpdateChatSummary(ctx, database.UpdateChatSummaryParams{ - ID: chat.ID, - ExpectedHistoryVersion: expectedHistoryVersion, - ExpectedGenerationStartedAt: expectedGenerationStartedAt, - Summary: sqlSummary, + ID: chat.ID, + ExpectedHistoryVersion: expectedHistoryVersion, + Summary: sqlSummary, }) if err != nil { logger.Warn(ctx, "failed to update chat summary", slog.F("chat_id", chat.ID), slog.Error(err)) - return false + return } if affected == 0 { logger.Info(ctx, "skipped stale chat summary update", @@ -4923,21 +4845,12 @@ func (p *Server) updateChatSummary( slog.F("summary_length", len(summary)), slog.F("expected_history_version", expectedHistoryVersion), ) - return false + return } updatedChat := chat updatedChat.Summary = sqlSummary - if expectedGenerationStartedAt.Valid { - p.publishChatSummaryGenerationEvent( - updatedChat, - codersdk.ChatWatchEventKindChatSummaryChange, - expectedGenerationStartedAt.Time, - ) - } else { - p.publishChatPubsubEvent(updatedChat, codersdk.ChatWatchEventKindChatSummaryChange, nil) - } - return true + p.publishChatPubsubEvent(updatedChat, codersdk.ChatWatchEventKindChatSummaryChange, nil) } func (p *Server) storeSubagentReportSummaryAsync( @@ -4972,12 +4885,11 @@ func (p *Server) storeSubagentReportSummary( slog.F("chat_id", chat.ID), slog.Error(err)) return } - // Extracted from the report rather than generated, so no bullets. - summary := formatChatSummaryMarkdown(subagentReportSummarySnippet(report), nil) + summary := subagentReportSummarySnippet(report) if summary == "" { return } - _ = p.updateChatSummary(ctx, logger, chat, chat.HistoryVersion, sql.NullTime{}, summary) + p.updateChatSummary(ctx, logger, chat, chat.HistoryVersion, summary) } func (p *Server) webpushConfigured() bool { diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index fe54859462161..186977f4a7e8c 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -82,62 +82,6 @@ func (t *testMCPAgentTool) MCPServerConfigID() uuid.UUID { return t.configID } -func TestFailChatSummaryGeneration(t *testing.T) { - t.Parallel() - - ctrl := gomock.NewController(t) - db := dbmock.NewMockStore(ctrl) - ps := newRecordingPubsub(dbpubsub.NewInMemory()) - server := &Server{db: db, pubsub: ps} - chat := database.Chat{ID: uuid.New(), OwnerID: uuid.New()} - generationStartedAt := time.Now() - logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) - - db.EXPECT().ClearChatSummaryGeneration(gomock.Any(), database.ClearChatSummaryGenerationParams{ - ID: chat.ID, - GenerationStartedAt: generationStartedAt, - }).Return(int64(1), nil) - - server.failChatSummaryGeneration( - context.Background(), - logger, - chat, - generationStartedAt, - ) - - events := ps.watchEvents(t) - require.Len(t, events, 1) - require.Equal(t, codersdk.ChatWatchEventKindChatSummaryFailed, events[0].Kind) - require.NotNil(t, events[0].ChatSummaryGenerationStartedAt) - require.True(t, generationStartedAt.Equal(*events[0].ChatSummaryGenerationStartedAt)) -} - -func TestFailChatSummaryGenerationSuperseded(t *testing.T) { - t.Parallel() - - ctrl := gomock.NewController(t) - db := dbmock.NewMockStore(ctrl) - ps := newRecordingPubsub(dbpubsub.NewInMemory()) - server := &Server{db: db, pubsub: ps} - chat := database.Chat{ID: uuid.New(), OwnerID: uuid.New()} - generationStartedAt := time.Now() - logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) - - db.EXPECT().ClearChatSummaryGeneration(gomock.Any(), database.ClearChatSummaryGenerationParams{ - ID: chat.ID, - GenerationStartedAt: generationStartedAt, - }).Return(int64(0), nil) - - server.failChatSummaryGeneration( - context.Background(), - logger, - chat, - generationStartedAt, - ) - - require.Empty(t, ps.watchEvents(t)) -} - func TestUpdateChatSummary(t *testing.T) { t.Parallel() @@ -150,7 +94,6 @@ func TestUpdateChatSummary(t *testing.T) { server := &Server{db: db, pubsub: ps} chat := database.Chat{ID: uuid.New(), OwnerID: uuid.New(), HistoryVersion: 7} logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) - generationStartedAt := time.Now() caller := rbac.Subject{ ID: chat.OwnerID.String(), Type: rbac.SubjectTypeUser, @@ -160,10 +103,9 @@ func TestUpdateChatSummary(t *testing.T) { ctx := dbauthz.As(context.Background(), caller) db.EXPECT().UpdateChatSummary(gomock.Any(), database.UpdateChatSummaryParams{ - ID: chat.ID, - ExpectedHistoryVersion: chat.HistoryVersion, - ExpectedGenerationStartedAt: sql.NullTime{Time: generationStartedAt, Valid: true}, - Summary: sql.NullString{String: "trimmed summary", Valid: true}, + ID: chat.ID, + ExpectedHistoryVersion: chat.HistoryVersion, + Summary: sql.NullString{String: "trimmed summary", Valid: true}, }).DoAndReturn(func(ctx context.Context, _ database.UpdateChatSummaryParams) (int64, error) { actor, ok := dbauthz.ActorFromContext(ctx) require.True(t, ok, "summary writes must preserve the caller's actor") @@ -171,13 +113,11 @@ func TestUpdateChatSummary(t *testing.T) { return 1, nil }) - server.updateChatSummary(ctx, logger, chat, chat.HistoryVersion, sql.NullTime{Time: generationStartedAt, Valid: true}, " \n trimmed summary\t ") + server.updateChatSummary(ctx, logger, chat, chat.HistoryVersion, " \n trimmed summary\t ") events := ps.watchEvents(t) require.Len(t, events, 1) require.Equal(t, codersdk.ChatWatchEventKindChatSummaryChange, events[0].Kind) - require.NotNil(t, events[0].ChatSummaryGenerationStartedAt) - require.True(t, generationStartedAt.Equal(*events[0].ChatSummaryGenerationStartedAt)) require.NotNil(t, events[0].Chat.Summary) require.Equal(t, "trimmed summary", *events[0].Chat.Summary) }) @@ -191,7 +131,7 @@ func TestUpdateChatSummary(t *testing.T) { chat := database.Chat{ID: uuid.New(), OwnerID: uuid.New(), HistoryVersion: 7} logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) - server.updateChatSummary(context.Background(), logger, chat, chat.HistoryVersion, sql.NullTime{}, " \n\t ") + server.updateChatSummary(context.Background(), logger, chat, chat.HistoryVersion, " \n\t ") }) t.Run("SkipsEventOnStaleWrite", func(t *testing.T) { @@ -210,7 +150,7 @@ func TestUpdateChatSummary(t *testing.T) { Summary: sql.NullString{String: "stale summary", Valid: true}, }).Return(int64(0), nil) - server.updateChatSummary(context.Background(), logger, chat, chat.HistoryVersion, sql.NullTime{}, "stale summary") + server.updateChatSummary(context.Background(), logger, chat, chat.HistoryVersion, "stale summary") require.Empty(t, ps.watchEvents(t)) }) diff --git a/codersdk/chats.go b/codersdk/chats.go index c5d350386e6b0..52571b537543d 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -1859,10 +1859,6 @@ func NewDynamicTool[T any]( } } -// ChatSummaryGenerationTimeout bounds how long an interrupted generation can -// be replayed to newly connected chat watchers. -const ChatSummaryGenerationTimeout = 2 * time.Minute - // ChatWatchEventKind represents the kind of event in the chat watch stream. type ChatWatchEventKind string @@ -1872,14 +1868,12 @@ const ( // ChatWatchEventKindChatSummaryChange carries the persisted whole-chat // summary. It is distinct from SummaryChange (bound to last_turn_summary) so // the frontend updates one field without disturbing the other. - ChatWatchEventKindChatSummaryChange ChatWatchEventKind = "chat_summary_change" - ChatWatchEventKindChatSummaryGenerating ChatWatchEventKind = "chat_summary_generating" - ChatWatchEventKindChatSummaryFailed ChatWatchEventKind = "chat_summary_failed" - ChatWatchEventKindTitleChange ChatWatchEventKind = "title_change" - ChatWatchEventKindCreated ChatWatchEventKind = "created" - ChatWatchEventKindDeleted ChatWatchEventKind = "deleted" - ChatWatchEventKindDiffStatusChange ChatWatchEventKind = "diff_status_change" - ChatWatchEventKindActionRequired ChatWatchEventKind = "action_required" + ChatWatchEventKindChatSummaryChange ChatWatchEventKind = "chat_summary_change" + ChatWatchEventKindTitleChange ChatWatchEventKind = "title_change" + ChatWatchEventKindCreated ChatWatchEventKind = "created" + ChatWatchEventKindDeleted ChatWatchEventKind = "deleted" + ChatWatchEventKindDiffStatusChange ChatWatchEventKind = "diff_status_change" + ChatWatchEventKindActionRequired ChatWatchEventKind = "action_required" // ChatWatchEventKindContextDirty signals that the chat's pinned // workspace context changed: it drifted from the agent's latest // pushed snapshot, or hydration first populated it (a first-turn @@ -1895,15 +1889,9 @@ const ( // ActionRequired, ToolCalls contains the pending dynamic tool // invocations the client must execute and submit back. type ChatWatchEvent struct { - Kind ChatWatchEventKind `json:"kind"` - Chat Chat `json:"chat"` - // ChatSummaryGenerationStartedAt identifies the summary worker that emitted - // generating and terminal lifecycle events. - ChatSummaryGenerationStartedAt *time.Time `json:"chat_summary_generation_started_at,omitempty" format:"date-time"` - // ChatSummaryGenerationRemainingMS is present on chat_summary_generating - // events so clients do not restart the generation timeout after reconnecting. - ChatSummaryGenerationRemainingMS *int64 `json:"chat_summary_generation_remaining_ms,omitempty"` - ToolCalls []ChatStreamToolCall `json:"tool_calls,omitempty"` + Kind ChatWatchEventKind `json:"kind"` + Chat Chat `json:"chat"` + ToolCalls []ChatStreamToolCall `json:"tool_calls,omitempty"` } // ChatStreamEvent represents a real-time update for chat streaming. diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index f03ed130ff876..c08c263afe08a 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -1393,8 +1393,6 @@ curl -X GET http://coder-server:8080/api/v2/chats/watch \ ], "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" }, - "chat_summary_generation_remaining_ms": 0, - "chat_summary_generation_started_at": "2019-08-24T14:15:22Z", "kind": "status_change", "tool_calls": [ { diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 4e3ad19154d10..fa5502a5d7d9a 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -5394,8 +5394,6 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ], "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" }, - "chat_summary_generation_remaining_ms": 0, - "chat_summary_generation_started_at": "2019-08-24T14:15:22Z", "kind": "status_change", "tool_calls": [ { @@ -5409,13 +5407,11 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ### Properties -| Name | Type | Required | Restrictions | Description | -|----------------------------------------|---------------------------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------| -| `chat` | [codersdk.Chat](#codersdkchat) | false | | | -| `chat_summary_generation_remaining_ms` | integer | false | | Chat summary generation remaining ms is present on chat_summary_generating events so clients do not restart the generation timeout after reconnecting. | -| `chat_summary_generation_started_at` | string | false | | Chat summary generation started at identifies the summary worker that emitted generating and terminal lifecycle events. | -| `kind` | [codersdk.ChatWatchEventKind](#codersdkchatwatcheventkind) | false | | | -| `tool_calls` | array of [codersdk.ChatStreamToolCall](#codersdkchatstreamtoolcall) | false | | | +| Name | Type | Required | Restrictions | Description | +|--------------|---------------------------------------------------------------------|----------|--------------|-------------| +| `chat` | [codersdk.Chat](#codersdkchat) | false | | | +| `kind` | [codersdk.ChatWatchEventKind](#codersdkchatwatcheventkind) | false | | | +| `tool_calls` | array of [codersdk.ChatStreamToolCall](#codersdkchatstreamtoolcall) | false | | | ## codersdk.ChatWatchEventKind @@ -5427,9 +5423,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in #### Enumerated Values -| Value(s) | -|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action_required`, `chat_summary_change`, `chat_summary_failed`, `chat_summary_generating`, `context_dirty`, `created`, `deleted`, `diff_status_change`, `status_change`, `summary_change`, `title_change` | +| Value(s) | +|----------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action_required`, `chat_summary_change`, `context_dirty`, `created`, `deleted`, `diff_status_change`, `status_change`, `summary_change`, `title_change` | ## codersdk.ChatWorkspaceTTLResponse diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index 8328c51ce9351..cc83adb3a7769 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -3685,8 +3685,6 @@ describe("semantic cache operations: prefix invalidations", () => { const expectedByKind: Record = { action_required: true, chat_summary_change: false, - chat_summary_failed: false, - chat_summary_generating: false, context_dirty: false, created: false, deleted: false, @@ -3766,8 +3764,6 @@ describe("semantic cache operations: prefix invalidations", () => { const expectedByKind: Record = { action_required: true, chat_summary_change: false, - chat_summary_failed: false, - chat_summary_generating: false, context_dirty: false, created: false, deleted: false, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index c7e9856763464..1624e90af5b3f 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3624,16 +3624,6 @@ export interface ChatUser extends MinimalUser { export interface ChatWatchEvent { readonly kind: ChatWatchEventKind; readonly chat: Chat; - /** - * ChatSummaryGenerationStartedAt identifies the summary worker that emitted - * generating and terminal lifecycle events. - */ - readonly chat_summary_generation_started_at?: string; - /** - * ChatSummaryGenerationRemainingMS is present on chat_summary_generating - * events so clients do not restart the generation timeout after reconnecting. - */ - readonly chat_summary_generation_remaining_ms?: number; readonly tool_calls?: readonly ChatStreamToolCall[]; } @@ -3641,8 +3631,6 @@ export interface ChatWatchEvent { export type ChatWatchEventKind = | "action_required" | "chat_summary_change" - | "chat_summary_failed" - | "chat_summary_generating" | "context_dirty" | "created" | "deleted" @@ -3654,8 +3642,6 @@ export type ChatWatchEventKind = export const ChatWatchEventKinds: ChatWatchEventKind[] = [ "action_required", "chat_summary_change", - "chat_summary_failed", - "chat_summary_generating", "context_dirty", "created", "deleted", diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 5bcfbe6dbf260..aeaaf991713a7 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -162,7 +162,6 @@ const AgentChatPage: FC = () => { setChatErrorReason, clearChatErrorReason, onChatReady, - summaryGeneratingChatIds, } = useOutletContext(); const queryClient = useQueryClient(); const { permissions, user: currentUser } = useAuthenticated(); @@ -1149,9 +1148,6 @@ const AgentChatPage: FC = () => { canShareChat={canShareChat} workspace={workspace} workspaceAgent={workspaceAgent} - isChatSummaryGenerating={ - summaryGeneratingChatIds?.has(agentId) ?? false - } store={store} initialMessages={chatMessagesList ?? []} editing={{ ...editing, handleEditUserMessage }} diff --git a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx index db34366a2627c..02daa71c753c7 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx @@ -163,7 +163,6 @@ const StoryAgentChatPageView: FC = ({ chat: buildChat(chat), persistedError: undefined as ChatDetailError | undefined, parentChat: undefined as TypesGen.Chat | undefined, - isChatSummaryGenerating: false, effectiveSelectedModel: defaultModelID, setSelectedModel: fn(), modelOptions: defaultModelOptions, diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 2c778b9b7ad06..e4c50c94e1c0e 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -111,7 +111,6 @@ interface AgentChatPageViewProps { canShareChat: boolean; workspaceAgent?: TypesGen.WorkspaceAgent; workspace?: TypesGen.Workspace; - isChatSummaryGenerating: boolean; // Store handle. store: ChatStoreHandle; @@ -287,7 +286,6 @@ export const AgentChatPageView: FC = ({ canShareChat, workspaceAgent, workspace, - isChatSummaryGenerating, store, initialMessages, editing, @@ -661,7 +659,6 @@ export const AgentChatPageView: FC = ({ ); case "git": diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx index 849fb0c6d13f9..15adf85bd3844 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx @@ -1010,8 +1010,6 @@ const agentsWithAgentChatPageRouting = { }; const WATCHED_CHAT_ID = "chat-watched"; -const WATCHED_SUMMARY_GENERATION_STARTED_AT = "2026-08-27T18:00:00.000Z"; -const NEXT_WATCHED_SUMMARY_GENERATION_STARTED_AT = "2026-08-27T18:00:01.000Z"; const watchedChatCost: TypesGen.ChatCost = { chat_id: WATCHED_CHAT_ID, total_cost_micros: 1_250_000, @@ -1062,16 +1060,12 @@ const chatWatchEvent = ( kind: TypesGen.ChatWatchEventKind, chat: Chat, delayMs = 0, - chatSummaryGenerationRemainingMs?: number, - chatSummaryGenerationStartedAt?: string, connectionIndex?: number, ) => ({ event: "message" as const, data: JSON.stringify({ kind, chat, - chat_summary_generation_remaining_ms: chatSummaryGenerationRemainingMs, - chat_summary_generation_started_at: chatSummaryGenerationStartedAt, } satisfies TypesGen.ChatWatchEvent), delayMs, connectionIndex, @@ -1122,19 +1116,10 @@ export const SummaryWatchEventsUpdateOpenPanel: Story = { }; }, parameters: watchedChatPageParameters(watchedChat(), [ - chatWatchEvent( - "chat_summary_generating", - watchedChat(), - 750, - undefined, - WATCHED_SUMMARY_GENERATION_STARTED_AT, - ), chatWatchEvent( "chat_summary_change", watchedChat({ summary: "Generated summary from the watch event." }), - 3_000, - undefined, - WATCHED_SUMMARY_GENERATION_STARTED_AT, + 750, ), ]), play: async ({ canvasElement }) => { @@ -1143,17 +1128,7 @@ export const SummaryWatchEventsUpdateOpenPanel: Story = { name: "Summary", }); const summary = within(summaryPanel); - expect( - await summary.findByText("Not enough details to summarize."), - ).toBeVisible(); - - const status = await summary.findByRole("status", undefined, { - timeout: 3_000, - }); - expect(status).toHaveTextContent("Generating summary"); - expect( - summary.queryByText("Not enough details to summarize."), - ).not.toBeInTheDocument(); + expect(await summary.findByText("No summary yet.")).toBeVisible(); expect( await summary.findByText( @@ -1166,72 +1141,7 @@ export const SummaryWatchEventsUpdateOpenPanel: Story = { }, }; -export const SummaryReplayUsesRemainingTimeout: Story = { - decorators: [withProxyProvider()], - beforeEach: () => { - mockChats([watchedChat()]); - spyOn(API.experimental, "getChat").mockResolvedValue(watchedChat()); - const cleanup = mockAgentChatPageAPIs(); - clearPersistedSidebarTabId(WATCHED_CHAT_ID); - localStorage.setItem(RIGHT_PANEL_OPEN_KEY, "true"); - return () => { - clearPersistedSidebarTabId(WATCHED_CHAT_ID); - cleanup(); - }; - }, - parameters: { - ...watchedChatPageParameters(watchedChat(), [ - chatWatchEvent( - "chat_summary_generating", - watchedChat(), - 750, - 1_000, - WATCHED_SUMMARY_GENERATION_STARTED_AT, - ), - ]), - features: ["aibridge"], - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const summaryPanel = await canvas.findByRole("tabpanel", { - name: "Summary", - }); - const summary = within(summaryPanel); - expect( - await summary.findByText("Not enough details to summarize."), - ).toBeVisible(); - expect( - await summary.findByRole("status", undefined, { timeout: 3_000 }), - ).toHaveTextContent("Generating summary"); - const getChatCostMock = mocked(API.experimental.getChatCost); - await waitFor( - () => { - expect(getChatCostMock).toHaveBeenCalledWith(WATCHED_CHAT_ID); - }, - { timeout: 3_000 }, - ); - getChatCostMock.mockClear(); - const getChatMock = mocked(API.experimental.getChat); - getChatMock.mockResolvedValue( - watchedChat({ summary: "Summary completed without a terminal event." }), - ); - getChatMock.mockClear(); - expect( - await summary.findByText( - "Summary completed without a terminal event.", - {}, - { timeout: 3_000 }, - ), - ).toBeVisible(); - expect(summary.queryByRole("status")).not.toBeInTheDocument(); - expect(getChatMock).toHaveBeenCalledWith(WATCHED_CHAT_ID); - await waitFor(() => { - expect(getChatCostMock).toHaveBeenCalledWith(WATCHED_CHAT_ID); - }); - }, -}; - -export const SummaryReconnectClearsStaleGeneratingState: Story = { +export const SummaryReconnectRefreshesPersistedSummary: Story = { decorators: [withProxyProvider()], beforeEach: () => { mockChats([watchedChat()]); @@ -1246,14 +1156,6 @@ export const SummaryReconnectClearsStaleGeneratingState: Story = { }, parameters: watchedChatPageParameters(watchedChat(), [ { event: "open", connectionIndex: 0 }, - chatWatchEvent( - "chat_summary_generating", - watchedChat(), - 750, - undefined, - WATCHED_SUMMARY_GENERATION_STARTED_AT, - 0, - ), { event: "close", delayMs: 3_000, connectionIndex: 0 }, { event: "open", connectionIndex: 1 }, ]), @@ -1263,12 +1165,7 @@ export const SummaryReconnectClearsStaleGeneratingState: Story = { name: "Summary", }); const summary = within(summaryPanel); - expect( - await summary.findByText("Not enough details to summarize."), - ).toBeVisible(); - expect( - await summary.findByRole("status", undefined, { timeout: 3_000 }), - ).toHaveTextContent("Generating summary"); + expect(await summary.findByText("No summary yet.")).toBeVisible(); const getChatMock = mocked(API.experimental.getChat); getChatMock.mockResolvedValue( watchedChat({ summary: "Summary completed while disconnected." }), @@ -1286,162 +1183,6 @@ export const SummaryReconnectClearsStaleGeneratingState: Story = { }, }; -export const StaleSummaryFailureKeepsNewGeneration: Story = { - decorators: [withProxyProvider()], - beforeEach: () => { - mockChats([watchedChat()]); - const cleanup = mockAgentChatPageAPIs(); - clearPersistedSidebarTabId(WATCHED_CHAT_ID); - localStorage.setItem(RIGHT_PANEL_OPEN_KEY, "true"); - return () => { - clearPersistedSidebarTabId(WATCHED_CHAT_ID); - cleanup(); - }; - }, - parameters: watchedChatPageParameters(watchedChat(), [ - chatWatchEvent( - "chat_summary_generating", - watchedChat(), - 500, - undefined, - WATCHED_SUMMARY_GENERATION_STARTED_AT, - ), - chatWatchEvent( - "chat_summary_generating", - watchedChat(), - 1_000, - undefined, - NEXT_WATCHED_SUMMARY_GENERATION_STARTED_AT, - ), - chatWatchEvent( - "chat_summary_generating", - watchedChat(), - 1_250, - undefined, - WATCHED_SUMMARY_GENERATION_STARTED_AT, - ), - chatWatchEvent( - "chat_summary_failed", - watchedChat(), - 1_500, - undefined, - WATCHED_SUMMARY_GENERATION_STARTED_AT, - ), - chatWatchEvent( - "title_change", - watchedChat({ title: "Stale failure ignored" }), - 2_000, - ), - chatWatchEvent( - "chat_summary_failed", - watchedChat(), - 3_500, - undefined, - NEXT_WATCHED_SUMMARY_GENERATION_STARTED_AT, - ), - chatWatchEvent( - "chat_summary_generating", - watchedChat(), - 4_000, - undefined, - WATCHED_SUMMARY_GENERATION_STARTED_AT, - ), - chatWatchEvent( - "title_change", - watchedChat({ title: "Old generation ignored after completion" }), - 4_500, - ), - ]), - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const summaryPanel = await canvas.findByRole("tabpanel", { - name: "Summary", - }); - const summary = within(summaryPanel); - expect( - await summary.findByText("Not enough details to summarize."), - ).toBeVisible(); - expect( - await summary.findByRole("status", undefined, { timeout: 3_000 }), - ).toHaveTextContent("Generating summary"); - expect( - await canvas.findAllByText( - "Stale failure ignored", - {}, - { timeout: 4_000 }, - ), - ).not.toHaveLength(0); - expect(summary.getByRole("status")).toHaveTextContent("Generating summary"); - expect( - await summary.findByText( - "Not enough details to summarize.", - {}, - { timeout: 5_000 }, - ), - ).toBeVisible(); - expect(summary.queryByRole("status")).not.toBeInTheDocument(); - expect( - await canvas.findAllByText( - "Old generation ignored after completion", - {}, - { timeout: 6_000 }, - ), - ).not.toHaveLength(0); - expect(summary.queryByRole("status")).not.toBeInTheDocument(); - }, -}; - -export const SummaryFailureClearsGeneratingState: Story = { - decorators: [withProxyProvider()], - beforeEach: () => { - mockChats([watchedChat()]); - const cleanup = mockAgentChatPageAPIs(); - clearPersistedSidebarTabId(WATCHED_CHAT_ID); - localStorage.setItem(RIGHT_PANEL_OPEN_KEY, "true"); - return () => { - clearPersistedSidebarTabId(WATCHED_CHAT_ID); - cleanup(); - }; - }, - parameters: watchedChatPageParameters(watchedChat(), [ - chatWatchEvent( - "chat_summary_generating", - watchedChat(), - 750, - undefined, - WATCHED_SUMMARY_GENERATION_STARTED_AT, - ), - chatWatchEvent( - "chat_summary_failed", - watchedChat(), - 3_000, - undefined, - WATCHED_SUMMARY_GENERATION_STARTED_AT, - ), - ]), - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const summaryPanel = await canvas.findByRole("tabpanel", { - name: "Summary", - }); - const summary = within(summaryPanel); - expect( - await summary.findByText("Not enough details to summarize."), - ).toBeVisible(); - expect( - await summary.findByRole("status", undefined, { timeout: 3_000 }), - ).toHaveTextContent("Generating summary"); - expect( - await summary.findByText( - "Not enough details to summarize.", - {}, - { timeout: 5_000 }, - ), - ).toBeVisible(); - expect(summary.queryByRole("status")).not.toBeInTheDocument(); - }, -}; - export const ArchiveWatchEventKeepsOpenChatMounted: Story = { decorators: [withProxyProvider()], beforeEach: () => { diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.test.ts b/site/src/pages/AgentsPage/AgentsPageLayout.test.ts index ecfda5468b020..24191acdcc68b 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.test.ts +++ b/site/src/pages/AgentsPage/AgentsPageLayout.test.ts @@ -1039,12 +1039,6 @@ describe(chatCostIdToInvalidate.name, () => { eventKind: "chat_summary_change", expected: "chat-1", }, - { - name: "invalidates when whole-chat summary generation fails", - updatedChat: chatForFilterInvalidation({ status: "waiting" }), - eventKind: "chat_summary_failed", - expected: "chat-1", - }, { name: "invalidates the root's tree cost for a subagent summary change", updatedChat: chatForFilterInvalidation({ diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.tsx index 8b2d960be7baa..2ba5b8d7e414d 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.tsx @@ -120,8 +120,6 @@ export interface AgentsPageOutletContext { onToggleSidebarCollapsed: () => void; onExpandSidebar: () => void; onChatReady: () => void; - /** Root chats for which the server has reported active summary generation. */ - summaryGeneratingChatIds?: ReadonlySet; } const FILTER_MEMBERSHIP_EVENT_KINDS = new Set([ @@ -139,15 +137,10 @@ export const shouldInvalidateFilteredChatList = ( // status, so invalidate the root-keyed cost query when those events arrive. const POST_TURN_BILLED_EVENT_KINDS = new Set([ "chat_summary_change", - "chat_summary_failed", "summary_change", "title_change", ]); -// Matches chatd.chatSummaryWorkTimeout so a failed generation cannot leave -// the transient loading state visible indefinitely. -const chatSummaryGeneratingTimeoutMs = 120_000; - export const chatCostIdToInvalidate = ( chat: TypesGen.Chat, eventKind: TypesGen.ChatWatchEventKind, @@ -185,9 +178,6 @@ const AgentsPageLayout: FC = () => { setSearchParams, ); const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false); - const [summaryGeneratingChatIds, setSummaryGeneratingChatIds] = useState< - ReadonlySet - >(() => new Set()); // The global CSS sets scrollbar-gutter: stable on to prevent // layout shift on pages that toggle scrollbars. The agents page @@ -566,106 +556,7 @@ const AgentsPageLayout: FC = () => { void invalidateChatSearches(queryClient); }, [agentId, queryClient]); useEffect(() => { - const summaryGeneratingTimeouts = new Map< - string, - ReturnType - >(); - const latestSummaryGenerationStartedAt = new Map(); - const clearSummaryGenerating = ( - chatId: string, - expectedGenerationStartedAt?: string, - ) => { - const latestGenerationStartedAt = - latestSummaryGenerationStartedAt.get(chatId); - if ( - expectedGenerationStartedAt !== undefined && - latestGenerationStartedAt !== undefined && - expectedGenerationStartedAt !== latestGenerationStartedAt - ) { - return false; - } - setSummaryGeneratingChatIds((current) => { - if (!current.has(chatId)) { - return current; - } - const next = new Set(current); - next.delete(chatId); - return next; - }); - return true; - }; - const clearAllSummaryGenerating = () => { - for (const timeout of summaryGeneratingTimeouts.values()) { - clearTimeout(timeout); - } - summaryGeneratingTimeouts.clear(); - latestSummaryGenerationStartedAt.clear(); - setSummaryGeneratingChatIds((current) => - current.size === 0 ? current : new Set(), - ); - }; - const markSummaryGenerating = ( - chatId: string, - timeoutMs = chatSummaryGeneratingTimeoutMs, - generationStartedAt?: string, - ) => { - const previousGenerationStartedAt = - latestSummaryGenerationStartedAt.get(chatId); - if ( - generationStartedAt !== undefined && - previousGenerationStartedAt !== undefined - ) { - const generationStartedAtMs = Date.parse(generationStartedAt); - const previousGenerationStartedAtMs = Date.parse( - previousGenerationStartedAt, - ); - if ( - generationStartedAt === previousGenerationStartedAt || - (Number.isFinite(generationStartedAtMs) && - Number.isFinite(previousGenerationStartedAtMs) && - generationStartedAtMs <= previousGenerationStartedAtMs) - ) { - return; - } - } - const previousTimeout = summaryGeneratingTimeouts.get(chatId); - if (previousTimeout !== undefined) { - clearTimeout(previousTimeout); - } - if (generationStartedAt === undefined) { - latestSummaryGenerationStartedAt.delete(chatId); - } else { - latestSummaryGenerationStartedAt.set(chatId, generationStartedAt); - } - const boundedTimeoutMs = Math.max( - 0, - Math.min(timeoutMs, chatSummaryGeneratingTimeoutMs), - ); - if (boundedTimeoutMs === 0) { - summaryGeneratingTimeouts.delete(chatId); - if (clearSummaryGenerating(chatId, generationStartedAt)) { - void invalidateChatEntity(queryClient, chatId); - void invalidateChatCostTree(queryClient, chatId); - } - return; - } - setSummaryGeneratingChatIds((current) => { - if (current.has(chatId)) { - return current; - } - return new Set(current).add(chatId); - }); - const timeout = setTimeout(() => { - summaryGeneratingTimeouts.delete(chatId); - if (clearSummaryGenerating(chatId, generationStartedAt)) { - void invalidateChatEntity(queryClient, chatId); - void invalidateChatCostTree(queryClient, chatId); - } - }, boundedTimeoutMs); - summaryGeneratingTimeouts.set(chatId, timeout); - }; - - const dispose = createReconnectingWebSocket({ + return createReconnectingWebSocket({ connect() { const ws = watchChats(); @@ -676,30 +567,6 @@ const AgentsPageLayout: FC = () => { } const chatEvent = event.parsedMessage; const updatedChat = chatEvent.chat; - if (chatEvent.kind === "chat_summary_generating") { - markSummaryGenerating( - updatedChat.id, - chatEvent.chat_summary_generation_remaining_ms, - chatEvent.chat_summary_generation_started_at, - ); - } else if ( - chatEvent.kind === "chat_summary_change" || - chatEvent.kind === "chat_summary_failed" || - chatEvent.kind === "deleted" - ) { - if ( - clearSummaryGenerating( - updatedChat.id, - chatEvent.chat_summary_generation_started_at, - ) - ) { - const timeout = summaryGeneratingTimeouts.get(updatedChat.id); - if (timeout !== undefined) { - clearTimeout(timeout); - summaryGeneratingTimeouts.delete(updatedChat.id); - } - } - } if ( chatEvent.kind === "status_change" && !updatedChat.parent_chat_id @@ -717,7 +584,6 @@ const AgentsPageLayout: FC = () => { } if (chatEvent.kind === "deleted") { - latestSummaryGenerationStartedAt.delete(updatedChat.id); // The server publishes `deleted` when a chat is // archived (one event per family member); there is // no hard-delete wire event. Patch archive state in @@ -809,7 +675,6 @@ const AgentsPageLayout: FC = () => { return ws; }, onOpen() { - clearAllSummaryGenerating(); const activeChatId = activeChatIDRef.current; if (activeChatId) { void invalidateChatEntity(queryClient, activeChatId); @@ -828,12 +693,6 @@ const AgentsPageLayout: FC = () => { void invalidateChatSearches(queryClient); }, }); - return () => { - dispose(); - for (const timeout of summaryGeneratingTimeouts.values()) { - clearTimeout(timeout); - } - }; }, [queryClient]); useAgentsPageKeybindings({ @@ -894,7 +753,6 @@ const AgentsPageLayout: FC = () => { onToggleSidebarCollapsed: handleToggleSidebarCollapsed, onExpandSidebar: () => setIsSidebarCollapsed(false), onChatReady: () => {}, - summaryGeneratingChatIds, }; return ( diff --git a/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx b/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx index dc7859a067d37..0e1a0a79fc626 100644 --- a/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx @@ -66,7 +66,6 @@ export const HeadlineAndBullets: Story = { const list = canvas.getByRole("list"); await expect(within(list).getAllByRole("listitem")).toHaveLength(3); - // Backticked identifiers render as inline code, not literal backticks. await expect(canvas.getByText("chatd.go")).toBeInTheDocument(); await expect(canvas.queryByText(/`/)).not.toBeInTheDocument(); }, @@ -162,17 +161,10 @@ export const NoSummary: Story = { ], play: async ({ canvasElement }) => { const canvas = within(canvasElement); + await expect(canvas.getByText("No summary yet.")).toBeInTheDocument(); await expect( - canvas.getByText("Not enough details to summarize."), + canvas.getByText("A recap of this chat will appear here when available."), ).toBeInTheDocument(); - await expect( - canvas.getByText( - "A recap of your chat will appear here after a few more messages.", - ), - ).toBeInTheDocument(); - await expect( - canvas.queryByText("Generating summary"), - ).not.toBeInTheDocument(); await expect(canvas.getByText("Created:")).toBeInTheDocument(); await expect(canvas.getByText("Updated:")).toBeInTheDocument(); await expect(canvas.getByText("Cost:")).toBeInTheDocument(); @@ -180,36 +172,6 @@ export const NoSummary: Story = { }, }; -export const GeneratingSummary: Story = { - args: { summary: null, isGenerating: true }, - decorators: [ - (Story) => ( -
    - -
    - ), - ], - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const status = canvas.getByRole("status"); - await expect(status).toHaveTextContent("Generating summary"); - await expect( - canvas.queryByText("Not enough details to summarize."), - ).not.toBeInTheDocument(); - await expect(canvas.getByText("Created:")).toBeInTheDocument(); - await expect(canvas.getByText("Cost:")).toBeInTheDocument(); - }, -}; - -export const RegeneratingSummary: Story = { - args: { summary: "Existing summary remains visible.", isGenerating: true }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - expect(canvas.getByRole("status")).toHaveTextContent("Generating summary"); - expect(canvas.getByText("Existing summary remains visible.")).toBeVisible(); - }, -}; - // A subagent's summary is its final report, persisted when it // completes, so an empty summary means the agent is still working. export const SubagentSummaryPending: Story = { diff --git a/site/src/pages/AgentsPage/components/ChatSummary.tsx b/site/src/pages/AgentsPage/components/ChatSummary.tsx index 8b1c616e6222b..4e7688871bc78 100644 --- a/site/src/pages/AgentsPage/components/ChatSummary.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummary.tsx @@ -4,12 +4,8 @@ import { InlineMarkdown } from "#/components/Markdown/InlineMarkdown"; import { Skeleton } from "#/components/Skeleton/Skeleton"; import { formatCostMicros } from "#/utils/currency"; import { DATE_FORMAT, formatDateTime } from "#/utils/time"; -import { Shimmer } from "./ChatElements/Shimmer"; const EMPTY_VALUE = "-"; -const EMPTY_SUMMARY_TITLE = "Not enough details to summarize."; -const EMPTY_SUMMARY_DESCRIPTION = - "A recap of your chat will appear here after a few more messages."; interface ChatSummaryProps { summary: string | null; @@ -24,8 +20,6 @@ interface ChatSummaryProps { showCost: boolean; /** Subagent summaries are the agent's final report, persisted when it completes, so the empty state reads as pending rather than absent. */ isSubagent?: boolean; - /** True while a root-chat summary is expected to land after a finished turn. */ - isGenerating?: boolean; } export const ChatSummary: FC = ({ @@ -38,7 +32,6 @@ export const ChatSummary: FC = ({ unpricedRequestCount, showCost, isSubagent, - isGenerating, }) => { const trimmedSummary = summary?.trim(); const hasCost = @@ -55,26 +48,7 @@ export const ChatSummary: FC = ({ Summary pending agent completion.

    ) : ( - - - Generating summary - - - ) : ( - EMPTY_SUMMARY_TITLE - ) - } - description={isGenerating ? undefined : EMPTY_SUMMARY_DESCRIPTION} - /> - )} - - {isGenerating && trimmedSummary && ( - - Generating summary - + )}
    @@ -116,15 +90,7 @@ export const ChatSummary: FC = ({ ); }; -interface ChatSummaryEmptyProps { - title: ReactNode; - description?: string; -} - -const ChatSummaryEmpty: FC = ({ - title, - description, -}) => ( +const ChatSummaryEmpty: FC = () => (
    = ({ className="size-5 text-content-secondary" />
    -

    {title}

    +

    + No summary yet. +

    - {description} + A recap of this chat will appear here when available.

    ); diff --git a/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx b/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx index 4b393b6b6fccb..a52154eab362f 100644 --- a/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx @@ -22,7 +22,6 @@ type MockRequestOptions = { chatError?: boolean; parentChatId?: string; rootChatId?: string; - status?: TypesGen.ChatStatus; }; const mockRequests = ({ @@ -31,7 +30,6 @@ const mockRequests = ({ chatError, parentChatId, rootChatId, - status, }: MockRequestOptions = {}) => { if (chatError) { spyOn(API.experimental, "getChat").mockRejectedValue( @@ -43,7 +41,6 @@ const mockRequests = ({ summary, ...(parentChatId ? { parent_chat_id: parentChatId } : {}), ...(rootChatId ? { root_chat_id: rootChatId } : {}), - ...(status ? { status } : {}), }); } @@ -120,7 +117,6 @@ export const SubagentSummaryPending: Story = { canvas.getByText("Summary pending agent completion."), ).toBeInTheDocument(); }); - expect(canvas.queryByText("Generating summary")).not.toBeInTheDocument(); }, }; @@ -165,9 +161,7 @@ export const NotVisible: Story = { expect( canvas.queryByText("Should never be fetched."), ).not.toBeInTheDocument(); - expect( - canvas.queryByText("Not enough details to summarize."), - ).not.toBeInTheDocument(); + expect(canvas.queryByText("No summary yet.")).not.toBeInTheDocument(); }, }; @@ -176,16 +170,11 @@ export const NoSummary: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await waitFor(() => { - expect( - canvas.getByText("Not enough details to summarize."), - ).toBeInTheDocument(); + expect(canvas.getByText("No summary yet.")).toBeInTheDocument(); }); expect( - canvas.getByText( - "A recap of your chat will appear here after a few more messages.", - ), + canvas.getByText("A recap of this chat will appear here when available."), ).toBeInTheDocument(); - expect(canvas.queryByText("Generating summary")).not.toBeInTheDocument(); expect(canvas.getByText("Created:")).toBeInTheDocument(); expect(canvas.getByText("Updated:")).toBeInTheDocument(); expect(canvas.getByText("Cost:")).toBeInTheDocument(); @@ -193,37 +182,6 @@ export const NoSummary: Story = { }, }; -export const GeneratingSummary: Story = { - args: { isGenerating: true }, - beforeEach: () => mockRequests({ status: "waiting" }), - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const status = await canvas.findByRole("status"); - expect(status).toHaveTextContent("Generating summary"); - expect( - canvas.queryByText("Not enough details to summarize."), - ).not.toBeInTheDocument(); - expect(canvas.getByText("Created:")).toBeInTheDocument(); - expect(canvas.getByText("Cost:")).toBeInTheDocument(); - }, -}; - -// A short completed chat (for example, a single "test" prompt) is waiting but -// never emits chat_summary_generating, so it must keep the empty state. -export const ShortChatDoesNotGenerateSummary: Story = { - beforeEach: () => mockRequests({ status: "waiting" }), - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await waitFor(() => { - expect( - canvas.getByText("Not enough details to summarize."), - ).toBeInTheDocument(); - }); - expect(canvas.queryByText("Generating summary")).not.toBeInTheDocument(); - expect(canvas.getByText("Cost:")).toBeInTheDocument(); - }, -}; - export const GatewayUnavailable: Story = { parameters: { features: [] }, beforeEach: () => mockRequests({ summary: "Gateway is off here." }), diff --git a/site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx b/site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx index 182c67be1df04..66a5a1615a5de 100644 --- a/site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx @@ -11,14 +11,11 @@ type ChatSummaryPanelProps = { chatId: string; /** Gate reads on tab visibility so the chat and cost queries don't run while the tab is hidden. */ isVisible: boolean; - /** Set only after the server reports that summary generation started. */ - isGenerating?: boolean; }; export const ChatSummaryPanel: FC = ({ chatId, isVisible, - isGenerating, }) => { const showCost = Boolean(useFeatureVisibility().aibridge); const chatQuery = useQuery({ ...chat(chatId), enabled: isVisible }); @@ -50,7 +47,6 @@ export const ChatSummaryPanel: FC = ({