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

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a3b2283
feat: harden chat generation runtime instrumentation for billing
jaaydenh Jul 23, 2026
7c2486b
fix: do not bill tool-batch episode spans on interrupted attachments
jaaydenh Jul 23, 2026
525b0ad
refactor(coderd/x/chatd): trim comments to edge cases and invariants
jaaydenh Jul 23, 2026
f122655
Merge branch 'main' into jaayden/codagt-835-b1-audit-and-harden-gener…
jaaydenh Jul 23, 2026
359c93a
fix(coderd): bill interrupted turns for the model invocation window
jaaydenh Jul 27, 2026
4673ffe
Merge remote-tracking branch 'origin/main' into jaayden/codagt-835-b1…
jaaydenh Jul 27, 2026
4b6c128
fix(coderd/database/migrations): renumber runtime_ms comment migration
jaaydenh Jul 27, 2026
d958346
Merge branch 'main' into jaayden/codagt-835-b1-audit-and-harden-gener…
jaaydenh Jul 30, 2026
9e58122
Merge branch 'main' into jaayden/codagt-835-b1-audit-and-harden-gener…
jaaydenh Jul 30, 2026
ca4334b
Merge branch 'main' into jaayden/codagt-835-b1-audit-and-harden-gener…
jaaydenh Aug 4, 2026
5de844f
Merge branch 'main' into jaayden/codagt-835-b1-audit-and-harden-gener…
jaaydenh Aug 4, 2026
e23efa7
Merge branch 'main' into jaayden/codagt-835-b1-audit-and-harden-gener…
jaaydenh Aug 5, 2026
2ec3cb8
Update coderd/x/chatd/ARCHITECTURE.md
jaaydenh Aug 6, 2026
7d1a13f
Update coderd/x/chatd/chatloop/chatloop.go
jaaydenh Aug 6, 2026
e7abe67
Update coderd/x/chatd/chatloop/chatloop.go
jaaydenh Aug 6, 2026
8a043cb
Update coderd/x/chatd/messagepartbuffer/message_part_buffer.go
jaaydenh Aug 6, 2026
1b85a75
updates for PR review
jaaydenh Aug 6, 2026
9d1374d
chore: update for buffer interface changes
jaaydenh Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions coderd/x/chatd/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,8 @@ The buffer exposes the following API:
- `CloseEpisode(chat_id, history_version, generation_attempt)`: closes an episode, preventing further parts from being added to it. May be called multiple times for a given episode, subsequent calls will be no-ops. Calling it on a non-existent episode creates the episode and closes it immediately. Concurrent parts of the system may race to create the episode and close it, so creating and closing in one operation prevents race conditions.
- `AddPart(chat_id, history_version, generation_attempt, content)`: adds a message part to the buffer. Returns a predefined error if the episode is not found or the array is full.
- `GetParts(chat_id, history_version, generation_attempt)`: returns the message parts for an episode. Returns a predefined error if the episode is not found.
- `StartModelInvocation(chat_id, history_version, generation_attempt)`: stamps the instant the episode opens its provider stream. Returns a predefined error if the episode is not found or already closed. Episodes that never invoke a model, such as local tool execution batches, are never stamped.
- `ModelInvokedAt(chat_id, history_version, generation_attempt)`: returns the instant stamped by `StartModelInvocation`, or the zero time when the episode is unknown or never opened a provider stream. It must be read before `CloseEpisode`, because closed episodes are garbage collected and reading afterwards races the cleanup loop. The interrupt goroutine reads it just before closing the episode and uses the span between that instant and the interrupt as the interrupted attempt's billable runtime.
- `SubscribeToEpisode(chat_id, history_version, generation_attempt)`: returns a go channel that will receive all message parts for the episode. It spawns a goroutine that delivers parts to the channel. It's live until the episode is closed or until a subscriber requests that the channel be closed. Once the goroutine delivers all message parts for a closed episode, it closes the channel and exits. If the episode is already closed at the time of the call, the goroutine delivers all message parts for the episode, closes the channel, and exits. `SubscribeToEpisode` does not return an error if the episode is not found: it waits for it to be created instead.

Closed episodes are garbage collected after at least 15 seconds since they were closed and when they have no active subscribers. The message part buffer maintains a garbage collection goroutine.
Expand Down
5 changes: 4 additions & 1 deletion coderd/x/chatd/attempt.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ type pendingDynamicToolCall struct {
Args string
}

// compactionOutcome contains a generated context summary.
// compactionOutcome contains a generated context summary. It must stay
// field-compatible with chatloop.CompactionResult; generateCompaction
// converts between the two directly.
type compactionOutcome struct {
SystemSummary string
SummaryReport string
Expand All @@ -53,6 +55,7 @@ type compactionOutcome struct {
UsagePercent float64
ContextTokens int64
ContextLimit int64
Runtime time.Duration
}

type compactionStatus int
Expand Down
12 changes: 11 additions & 1 deletion coderd/x/chatd/chatd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2925,6 +2925,7 @@ func TestActiveServer_InterruptionBehavior(t *testing.T) {
messages := chatMessages(ctx, t, db, chat.ID)
var userTexts []string
var foundPartial bool
var partialRuntime sql.NullInt64
for _, msg := range messages {
parts, parseErr := chatprompt.ParseContent(msg)
require.NoError(t, parseErr)
Expand All @@ -2939,12 +2940,16 @@ func TestActiveServer_InterruptionBehavior(t *testing.T) {
for _, part := range parts {
if part.Type == codersdk.ChatMessagePartTypeText && strings.Contains(part.Text, "partial assistant output") {
foundPartial = true
partialRuntime = msg.RuntimeMs
}
}
}
}
require.Equal(t, []string{"start and call a tool", "queued after interrupt"}, userTexts)
require.True(t, foundPartial)
// The interrupted attempt bills the model invocation window it
// opened, so the partial assistant row keeps a runtime.
require.True(t, partialRuntime.Valid)

parts := chatToolParts(ctx, t, db, chat.ID)
call := requireToolCallPart(t, parts, "read_file")
Expand Down Expand Up @@ -5909,7 +5914,12 @@ func TestActiveServer_BasicAssistantGenerationAndPromptPreparation(t *testing.T)
require.Equal(t, database.ChatMessageRoleAssistant, last.Role)
require.True(t, last.ContextLimit.Valid)
require.Equal(t, int64(4096), last.ContextLimit.Int64)
require.GreaterOrEqual(t, last.RuntimeMs.Int64, int64(0))
// runtime_ms is not asserted here: this stream is served in
Comment thread
jaaydenh marked this conversation as resolved.
// process and routinely finishes in under a millisecond, which
// InsertChatMessages stores as NULL. Runtime measurement and
// persistence are pinned deterministically in
// chatloop.TestGenerateAssistant_RecordsModelInvocationRuntime and
// TestInterruptTask_PartialAssistantKeepsAttemptRuntime.
requireTextPart(t, last, "done")

server = newActiveTestServer(t, db, ps, func(cfg *chatd.Config) {
Expand Down
23 changes: 19 additions & 4 deletions coderd/x/chatd/chatloop/chatloop.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,10 @@ type PersistedStep struct {
Content []fantasy.Content
Usage fantasy.Usage
ContextLimit sql.NullInt64
// Runtime is the wall-clock duration of this step,
// covering LLM streaming, tool execution, and retries.
// Zero indicates the duration was not measured (e.g.
// interrupted steps).
// Runtime is the wall-clock duration of the model invocation
Comment thread
jaaydenh marked this conversation as resolved.
// that produced this step's content, measured from just before
// the provider stream is opened until the stream is fully
// consumed.
Runtime time.Duration
// PendingDynamicToolCalls lists tool calls that target
// dynamic tools. When non-empty the chatloop exits with
Expand Down Expand Up @@ -219,6 +219,11 @@ type GenerateAssistantOptions struct {
ProviderOptions fantasy.ProviderOptions

PublishMessagePart func(codersdk.ChatMessageRole, codersdk.ChatMessagePart)
// OnModelStreamStart runs immediately before the provider stream is
// opened, at the instant PersistedStep.Runtime starts measuring. It
// lets callers record the billable window's start out of band, so an
// interrupted attempt bills the same window a completed step reports.
OnModelStreamStart func()
Logger slog.Logger
Metrics *Metrics
}
Expand Down Expand Up @@ -305,6 +310,13 @@ type GenerateCompactionOptions struct {
ProviderOptions fantasy.ProviderOptions

PublishMessagePart func(codersdk.ChatMessageRole, codersdk.ChatMessagePart)

// Clock measures the summary call duration. Required.
Clock quartz.Clock

// OnModelStreamStart runs immediately before the summary model call,
// at the instant CompactionResult.Runtime starts measuring.
OnModelStreamStart func()
}

// ProviderTool pairs a provider-native tool definition with an
Expand Down Expand Up @@ -400,6 +412,9 @@ func GenerateAssistant(ctx context.Context, opts GenerateAssistantOptions) (Assi
}

stepStart := opts.Clock.Now()
if opts.OnModelStreamStart != nil {
opts.OnModelStreamStart()
}
stepCtx := chatdebug.ReuseStep(ctx)
attempt, streamErr := guardedStream(
stepCtx,
Expand Down
14 changes: 14 additions & 0 deletions coderd/x/chatd/chatloop/compaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@ type CompactionResult struct {
UsagePercent float64
ContextTokens int64
ContextLimit int64
// Runtime is the wall-clock duration of the summarization model
// call, the compaction step's billable runtime (see
// PersistedStep.Runtime). Zero when the run was gated off before
// calling the model.
Runtime time.Duration
}

// GenerateCompaction generates one context summary and returns it without
Expand All @@ -140,6 +145,9 @@ func GenerateCompaction(ctx context.Context, opts GenerateCompactionOptions) (Co
if opts.Model == nil {
return CompactionResult{}, xerrors.New("chat model is required")
}
if opts.Clock == nil {
return CompactionResult{}, xerrors.New("clock is required")
}
config, ok := normalizedCompactionGenerateConfig(opts)
if !ok {
return CompactionResult{}, nil
Expand Down Expand Up @@ -171,11 +179,16 @@ func GenerateCompaction(ctx context.Context, opts GenerateCompactionOptions) (Co
)
}

summaryStart := opts.Clock.Now()
if opts.OnModelStreamStart != nil {
opts.OnModelStreamStart()
}
summary, err := generateCompactionSummary(ctx, opts.Model, opts.Messages, config)
if err != nil {
publishCompactionError(config, "failed to generate compaction summary")
return CompactionResult{}, err
}
summaryRuntime := opts.Clock.Since(summaryStart)
if summary == "" {
publishCompactionError(config, "compaction produced an empty summary")
return CompactionResult{}, xerrors.New("compaction produced an empty summary")
Expand All @@ -191,6 +204,7 @@ func GenerateCompaction(ctx context.Context, opts GenerateCompactionOptions) (Co
UsagePercent: usagePercent,
ContextTokens: contextTokens,
ContextLimit: contextLimit,
Runtime: summaryRuntime,
}
if config.PublishMessagePart != nil && config.ToolCallID != "" {
resultJSON, _ := json.Marshal(map[string]any{
Expand Down
15 changes: 15 additions & 0 deletions coderd/x/chatd/chatloop/compaction_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/coder/coder/v2/coderd/x/chatd/chatdebug"
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
)

func TestStartCompactionDebugRun_DoesNotReportDebugErrors(t *testing.T) {
Expand Down Expand Up @@ -326,6 +327,7 @@ func TestGenerateCompaction_ForceBypassesThresholdGates(t *testing.T) {
opts := tc.opts
opts.Model = newModel(&calls)
opts.Messages = messages
opts.Clock = quartz.NewMock(t)
result, err := GenerateCompaction(context.Background(), opts)
require.NoError(t, err)
require.Empty(t, result.SummaryReport)
Expand Down Expand Up @@ -365,8 +367,21 @@ func TestGenerateCompaction_DefaultSourceAutomatic(t *testing.T) {
ThresholdPercent: 70,
ContextLimit: 100,
StepUsage: fantasy.Usage{InputTokens: 90},
Clock: quartz.NewMock(t),
})
require.NoError(t, err)
require.Equal(t, "auto summary", result.SummaryReport)
require.Equal(t, CompactionSourceAutomatic, result.Source)
}

// TestGenerateCompaction_RequiresClock verifies a nil clock is
// rejected instead of silently falling back to a real clock; tests
// must supply their own.
func TestGenerateCompaction_RequiresClock(t *testing.T) {
t.Parallel()

_, err := GenerateCompaction(context.Background(), GenerateCompactionOptions{
Model: &chattest.FakeModel{ProviderName: "fake", ModelName: "fake-model"},
})
require.ErrorContains(t, err, "clock is required")
}
161 changes: 161 additions & 0 deletions coderd/x/chatd/chatloop/runtime_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package chatloop_test

import (
"context"
"testing"
"time"

"charm.land/fantasy"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"

"github.com/coder/coder/v2/coderd/x/chatd/chatloop"
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
"github.com/coder/quartz"
)

func TestGenerateAssistant_RecordsModelInvocationRuntime(t *testing.T) {
t.Parallel()

clock := quartz.NewMock(t)
model := &chattest.FakeModel{
ProviderName: "test-provider",
ModelName: "test-model",
StreamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
return func(yield func(fantasy.StreamPart) bool) {
if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextStart, ID: "t"}) {
return
}
if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextDelta, ID: "t", Delta: "hello"}) {
return
}
if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextEnd, ID: "t"}) {
return
}
clock.Advance(1500 * time.Millisecond)
yield(fantasy.StreamPart{
Type: fantasy.StreamPartTypeFinish,
FinishReason: fantasy.FinishReasonStop,
})
}, nil
},
}

outcome, err := chatloop.GenerateAssistant(context.Background(), chatloop.GenerateAssistantOptions{
Model: model,
Clock: clock,
})
require.NoError(t, err)
require.Equal(t, 1500*time.Millisecond, outcome.Step.Runtime)
}

// The interrupt path bills the window OnModelStreamStart opens, so that
// hook must fire at the instant PersistedStep.Runtime starts measuring.
func TestGenerateAssistant_ModelStreamStartMatchesRuntimeWindow(t *testing.T) {
t.Parallel()

clock := quartz.NewMock(t)
model := &chattest.FakeModel{
ProviderName: "test-provider",
ModelName: "test-model",
StreamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
return func(yield func(fantasy.StreamPart) bool) {
if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextStart, ID: "t"}) {
return
}
if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextDelta, ID: "t", Delta: "hello"}) {
return
}
if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextEnd, ID: "t"}) {
return
}
clock.Advance(1500 * time.Millisecond)
yield(fantasy.StreamPart{
Type: fantasy.StreamPartTypeFinish,
FinishReason: fantasy.FinishReasonStop,
})
}, nil
},
}

var startedAt []time.Time
outcome, err := chatloop.GenerateAssistant(context.Background(), chatloop.GenerateAssistantOptions{
Model: model,
Clock: clock,
OnModelStreamStart: func() {
startedAt = append(startedAt, clock.Now())
},
})
require.NoError(t, err)
require.Len(t, startedAt, 1)
require.Equal(t, outcome.Step.Runtime, clock.Since(startedAt[0]))
}

func TestGenerateAssistant_ErroredStreamReturnsNoStep(t *testing.T) {
t.Parallel()

clock := quartz.NewMock(t)
model := &chattest.FakeModel{
ProviderName: "test-provider",
ModelName: "test-model",
StreamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
return func(yield func(fantasy.StreamPart) bool) {
if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextStart, ID: "t"}) {
return
}
clock.Advance(1500 * time.Millisecond)
yield(fantasy.StreamPart{
Type: fantasy.StreamPartTypeError,
Error: xerrors.New("stream blew up"),
})
}, nil
},
}

outcome, err := chatloop.GenerateAssistant(context.Background(), chatloop.GenerateAssistantOptions{
Model: model,
Clock: clock,
})
require.Error(t, err)
require.Zero(t, outcome.Step.Runtime)
require.Empty(t, outcome.Step.Content)
}

func TestGenerateCompaction_RecordsRuntime(t *testing.T) {
t.Parallel()

clock := quartz.NewMock(t)
model := &chattest.FakeModel{
ProviderName: "test-provider",
ModelName: "test-model",
GenerateFn: func(_ context.Context, _ fantasy.Call) (*fantasy.Response, error) {
clock.Advance(1500 * time.Millisecond)
return &fantasy.Response{
Content: []fantasy.Content{
fantasy.TextContent{Text: "summary"},
},
}, nil
},
}

var startedAt []time.Time
result, err := chatloop.GenerateCompaction(context.Background(), chatloop.GenerateCompactionOptions{
Model: model,
Messages: []fantasy.Message{{
Role: fantasy.MessageRoleUser,
Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hello"}},
}},
ThresholdPercent: 70,
ContextLimit: 100,
StepUsage: fantasy.Usage{InputTokens: 90},
Clock: clock,
OnModelStreamStart: func() {
startedAt = append(startedAt, clock.Now())
},
})
require.NoError(t, err)
require.Equal(t, "summary", result.SummaryReport)
require.Equal(t, 1500*time.Millisecond, result.Runtime)
require.Len(t, startedAt, 1)
require.Equal(t, result.Runtime, clock.Since(startedAt[0]))
}
Loading
Loading