subagents wip

This commit is contained in:
Kyle Carberry
2026-02-24 11:32:00 -05:00
parent 649ef17cf2
commit c673044934
24 changed files with 31755 additions and 28901 deletions
+21
View File
@@ -14203,6 +14203,12 @@ const docTemplate = `{
"codersdk.ChatMessage": {
"type": "object",
"properties": {
"cache_creation_tokens": {
"type": "integer"
},
"cache_read_tokens": {
"type": "integer"
},
"chat_id": {
"type": "string",
"format": "uuid"
@@ -14213,6 +14219,9 @@ const docTemplate = `{
"type": "integer"
}
},
"context_limit": {
"type": "integer"
},
"created_at": {
"type": "string",
"format": "date-time"
@@ -14223,12 +14232,21 @@ const docTemplate = `{
"id": {
"type": "integer"
},
"input_tokens": {
"type": "integer"
},
"output_tokens": {
"type": "integer"
},
"parts": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.ChatMessagePart"
}
},
"reasoning_tokens": {
"type": "integer"
},
"role": {
"type": "string"
},
@@ -14237,6 +14255,9 @@ const docTemplate = `{
},
"tool_call_id": {
"type": "string"
},
"total_tokens": {
"type": "integer"
}
}
},
+23900 -22020
View File
File diff suppressed because it is too large Load Diff
+288 -30
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"regexp"
"strconv"
"strings"
"sync"
"time"
@@ -357,6 +358,7 @@ func NewProcessor(logger slog.Logger, db database.Store, opts ...Option) *Proces
}
p.subagentService = newSubagentService(p.db, p)
p.subagentService.setStreamManager(p.streamManager)
//nolint:gocritic // The chat processor is a system-level service.
ctx = dbauthz.AsSystemRestricted(ctx)
@@ -539,6 +541,9 @@ func (p *Processor) processChat(ctx context.Context, chat database.Chat) {
defer cancel(nil)
p.publishStatus(chat.ID, database.ChatStatusRunning)
if p.subagentService != nil {
p.subagentService.publishChildStatus(chat, database.ChatStatusRunning)
}
// Determine the final status to set when we're done.
status := database.ChatStatusWaiting
@@ -584,6 +589,15 @@ func (p *Processor) processChat(ctx context.Context, chat database.Chat) {
}
}
}
if status == database.ChatStatusWaiting {
hasPendingRequest, pendingErr := p.subagentService.HasPendingRequest(ctx, chat.ID)
if pendingErr != nil {
logger.Warn(ctx, "failed to check delegated pending request", slog.Error(pendingErr))
} else if hasPendingRequest {
status = database.ChatStatusPending
}
}
}
// Release the chat when done.
@@ -598,6 +612,9 @@ func (p *Processor) processChat(ctx context.Context, chat database.Chat) {
}
p.publishStatus(chat.ID, status)
if p.subagentService != nil {
p.subagentService.publishChildStatus(chat, status)
}
}()
if err := p.runChat(chatCtx, chat, logger, reportOnly, activeSubagentRequestID); err != nil {
@@ -638,7 +655,7 @@ func (p *Processor) runChat(
return xerrors.Errorf("build chat prompt: %w", err)
}
if reportOnly {
prompt = appendSystemInstruction(prompt, reportOnlyInstruction)
prompt = appendUserInstruction(prompt, reportOnlyInstruction)
} else if !chat.WorkspaceID.Valid {
prompt = prependSystemInstruction(prompt, defaultNoWorkspaceInstruction)
}
@@ -752,7 +769,11 @@ func (p *Processor) runChatWithAgent(
return currentConn, nil
}
persistAssistant := func(content []fantasy.Content) error {
persistAssistant := func(
content []fantasy.Content,
usage fantasy.Usage,
contextLimit sql.NullInt64,
) error {
if len(content) == 0 {
return nil
}
@@ -762,6 +783,7 @@ func (p *Processor) runChatWithAgent(
return err
}
hasUsage := usage != (fantasy.Usage{})
assistantMessage, err := p.db.InsertChatMessage(ctx, database.InsertChatMessageParams{
ChatID: chat.ID,
Role: string(fantasy.MessageRoleAssistant),
@@ -774,6 +796,16 @@ func (p *Processor) runChatWithAgent(
},
Hidden: false,
SubagentRequestID: subagentRequest,
InputTokens: usageNullInt64(usage.InputTokens, hasUsage),
OutputTokens: usageNullInt64(usage.OutputTokens, hasUsage),
TotalTokens: usageNullInt64(usage.TotalTokens, hasUsage),
ReasoningTokens: usageNullInt64(usage.ReasoningTokens, hasUsage),
CacheCreationTokens: usageNullInt64(
usage.CacheCreationTokens,
hasUsage,
),
CacheReadTokens: usageNullInt64(usage.CacheReadTokens, hasUsage),
ContextLimit: contextLimit,
})
if err != nil {
return xerrors.Errorf("insert assistant message: %w", err)
@@ -826,6 +858,7 @@ func (p *Processor) runChatWithAgent(
fantasy.WithStopConditions(fantasy.StepCountIs(maxChatSteps)),
)
sentinelPrompt := "__chatd_agent_prompt_sentinel_" + uuid.NewString()
applyAnthropicCaching := shouldApplyAnthropicPromptCaching(model)
result, err := agent.Stream(ctx, fantasy.AgentStreamCall{
Prompt: sentinelPrompt,
@@ -834,7 +867,12 @@ func (p *Processor) runChatWithAgent(
stepCtx context.Context,
options fantasy.PrepareStepFunctionOptions,
) (context.Context, fantasy.PrepareStepResult, error) {
return stepCtx, prepareAgentStepResult(options.Messages, sentinelPrompt, reportOnly), nil
return stepCtx, prepareAgentStepResult(
options.Messages,
sentinelPrompt,
reportOnly,
applyAnthropicCaching,
), nil
},
OnStepStart: func(_ int) error {
resetStepState()
@@ -906,7 +944,11 @@ func (p *Processor) runChatWithAgent(
toolResults := append([]ToolResultBlock(nil), stepToolResults...)
stepStateMu.Unlock()
if err := persistAssistant(stepAssistantContent(stepResult.Content, toolResults)); err != nil {
if err := persistAssistant(
stepAssistantContent(stepResult.Content, toolResults),
stepResult.Usage,
extractContextLimit(stepResult.ProviderMetadata),
); err != nil {
return err
}
for _, toolResult := range toolResults {
@@ -944,16 +986,55 @@ func prepareAgentStepResult(
messages []fantasy.Message,
sentinel string,
reportOnly bool,
anthropicCaching bool,
) fantasy.PrepareStepResult {
result := fantasy.PrepareStepResult{
Messages: stripAgentPromptSentinel(messages, sentinel),
}
if anthropicCaching {
result.Messages = addAnthropicPromptCaching(result.Messages)
}
if reportOnly {
result.ActiveTools = []string{toolSubagentReport}
}
return result
}
func shouldApplyAnthropicPromptCaching(model fantasy.LanguageModel) bool {
if model == nil {
return false
}
return model.Provider() == fantasyanthropic.Name
}
func addAnthropicPromptCaching(messages []fantasy.Message) []fantasy.Message {
for i := range messages {
messages[i].ProviderOptions = nil
}
providerOption := fantasy.ProviderOptions{
fantasyanthropic.Name: &fantasyanthropic.ProviderCacheControlOptions{
CacheControl: fantasyanthropic.CacheControl{Type: "ephemeral"},
},
}
lastSystemRoleIdx := -1
systemMessageUpdated := false
for i, msg := range messages {
if msg.Role == fantasy.MessageRoleSystem {
lastSystemRoleIdx = i
} else if !systemMessageUpdated && lastSystemRoleIdx >= 0 {
messages[lastSystemRoleIdx].ProviderOptions = providerOption
systemMessageUpdated = true
}
if i > len(messages)-3 {
messages[i].ProviderOptions = providerOption
}
}
return messages
}
func isAgentPromptSentinelMessage(message fantasy.Message, sentinel string) bool {
if message.Role != fantasy.MessageRoleUser {
return false
@@ -992,6 +1073,155 @@ func stepAssistantContent(content []fantasy.Content, toolResults []ToolResultBlo
return filtered
}
func usageNullInt64(value int64, valid bool) sql.NullInt64 {
if !valid {
return sql.NullInt64{}
}
return sql.NullInt64{
Int64: value,
Valid: valid,
}
}
func extractContextLimit(metadata fantasy.ProviderMetadata) sql.NullInt64 {
if len(metadata) == 0 {
return sql.NullInt64{}
}
encoded, err := json.Marshal(metadata)
if err != nil || len(encoded) == 0 {
return sql.NullInt64{}
}
var payload any
if err := json.Unmarshal(encoded, &payload); err != nil {
return sql.NullInt64{}
}
limit, ok := findContextLimitValue(payload)
if !ok {
return sql.NullInt64{}
}
return sql.NullInt64{
Int64: limit,
Valid: true,
}
}
func findContextLimitValue(value any) (int64, bool) {
var (
limit int64
found bool
)
collectContextLimitValues(value, func(candidate int64) {
if !found || candidate > limit {
limit = candidate
found = true
}
})
return limit, found
}
func collectContextLimitValues(value any, onValue func(int64)) {
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
if isContextLimitKey(key) {
if numeric, ok := numericContextLimitValue(child); ok {
onValue(numeric)
}
}
collectContextLimitValues(child, onValue)
}
case []any:
for _, child := range typed {
collectContextLimitValues(child, onValue)
}
}
}
func isContextLimitKey(key string) bool {
normalized := normalizeMetadataKey(key)
if normalized == "" {
return false
}
switch normalized {
case
"contextlimit",
"contextwindow",
"contextlength",
"maxcontext",
"maxcontexttokens",
"maxinputtokens",
"maxinputtoken",
"inputtokenlimit":
return true
}
return strings.Contains(normalized, "context") &&
(strings.Contains(normalized, "limit") ||
strings.Contains(normalized, "window") ||
strings.Contains(normalized, "length") ||
strings.HasPrefix(normalized, "max"))
}
func normalizeMetadataKey(key string) string {
var b strings.Builder
b.Grow(len(key))
for _, r := range key {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r)
case r >= 'A' && r <= 'Z':
b.WriteRune(r + ('a' - 'A'))
case r >= '0' && r <= '9':
b.WriteRune(r)
}
}
return b.String()
}
func numericContextLimitValue(value any) (int64, bool) {
switch typed := value.(type) {
case int64:
return positiveInt64(typed)
case int32:
return positiveInt64(int64(typed))
case int:
return positiveInt64(int64(typed))
case float64:
casted := int64(typed)
if typed > 0 && float64(casted) == typed {
return casted, true
}
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
if err == nil {
return positiveInt64(parsed)
}
case json.Number:
parsed, err := typed.Int64()
if err == nil {
return positiveInt64(parsed)
}
}
return 0, false
}
func positiveInt64(value int64) (int64, bool) {
if value <= 0 {
return 0, false
}
return value, true
}
func (p *Processor) maybeGenerateChatTitle(
ctx context.Context,
chat database.Chat,
@@ -1470,14 +1700,23 @@ func injectMissingToolUses(prompt []fantasy.Message, toolNameByCallID map[string
continue
}
// Walk backwards through the result to find the nearest
// preceding assistant message (skipping over other tool
// messages that belong to the same batch of results).
answeredByPrevious := make(map[string]struct{})
if len(result) > 0 && result[len(result)-1].Role == fantasy.MessageRoleAssistant {
for _, toolCall := range extractToolCallsFromMessageParts(result[len(result)-1].Content) {
toolCallID := sanitizeToolCallID(toolCall.ToolCallID)
if toolCallID == "" {
continue
for k := len(result) - 1; k >= 0; k-- {
if result[k].Role == fantasy.MessageRoleAssistant {
for _, toolCall := range extractToolCallsFromMessageParts(result[k].Content) {
toolCallID := sanitizeToolCallID(toolCall.ToolCallID)
if toolCallID == "" {
continue
}
answeredByPrevious[toolCallID] = struct{}{}
}
answeredByPrevious[toolCallID] = struct{}{}
break
}
if result[k].Role != fantasy.MessageRoleTool {
break
}
}
@@ -1605,7 +1844,11 @@ func prependSystemInstruction(prompt []fantasy.Message, instruction string) []fa
return out
}
func appendSystemInstruction(prompt []fantasy.Message, instruction string) []fantasy.Message {
// appendUserInstruction appends an instruction as a user message at
// the end of the prompt. This ensures the conversation ends with a
// user message, which is required by some providers (e.g. Anthropic
// rejects prompts ending with an assistant message).
func appendUserInstruction(prompt []fantasy.Message, instruction string) []fantasy.Message {
instruction = strings.TrimSpace(instruction)
if instruction == "" {
return prompt
@@ -1613,7 +1856,7 @@ func appendSystemInstruction(prompt []fantasy.Message, instruction string) []fan
out := make([]fantasy.Message, 0, len(prompt)+1)
out = append(out, prompt...)
out = append(out, fantasy.Message{
Role: fantasy.MessageRoleSystem,
Role: fantasy.MessageRoleUser,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: instruction},
},
@@ -2473,12 +2716,12 @@ func (p *Processor) agentTools(
ToolCallID: call.ID,
ToolName: call.Name,
Result: map[string]any{
"chat_id": chatID.String(),
"title": targetChat.Title,
"request_id": awaitResult.RequestID.String(),
"report": awaitResult.Report,
"duration_ms": awaitResult.DurationMS,
"status": "completed",
"chat_id": chatID.String(),
"title": targetChat.Title,
"request_id": awaitResult.RequestID.String(),
"report": awaitResult.Report,
"duration_ms": awaitResult.DurationMS,
"status": "completed",
},
}), nil
},
@@ -2632,13 +2875,13 @@ func (p *Processor) agentTools(
ToolCallID: call.ID,
ToolName: call.Name,
Result: map[string]any{
"chat_id": chatSnapshot.ID.String(),
"title": chatSnapshot.Title,
"request_id": awaitResult.RequestID.String(),
"report": awaitResult.Report,
"duration_ms": awaitResult.DurationMS,
"reported": true,
"status": "reported",
"chat_id": chatSnapshot.ID.String(),
"title": chatSnapshot.Title,
"request_id": awaitResult.RequestID.String(),
"report": awaitResult.Report,
"duration_ms": awaitResult.DurationMS,
"reported": true,
"status": "reported",
},
}), nil
},
@@ -3061,11 +3304,18 @@ func toolResultBlockFromAgentToolResult(content fantasy.ToolResultContent) ToolR
func SDKChatMessage(m database.ChatMessage) codersdk.ChatMessage {
msg := codersdk.ChatMessage{
ID: m.ID,
ChatID: m.ChatID,
CreatedAt: m.CreatedAt,
Role: m.Role,
Hidden: m.Hidden,
ID: m.ID,
ChatID: m.ChatID,
CreatedAt: m.CreatedAt,
Role: m.Role,
Hidden: m.Hidden,
InputTokens: nullInt64Ptr(m.InputTokens),
OutputTokens: nullInt64Ptr(m.OutputTokens),
TotalTokens: nullInt64Ptr(m.TotalTokens),
ReasoningTokens: nullInt64Ptr(m.ReasoningTokens),
CacheCreationTokens: nullInt64Ptr(m.CacheCreationTokens),
CacheReadTokens: nullInt64Ptr(m.CacheReadTokens),
ContextLimit: nullInt64Ptr(m.ContextLimit),
}
if m.Content.Valid {
msg.Content = m.Content.RawMessage
@@ -3082,3 +3332,11 @@ func SDKChatMessage(m database.ChatMessage) codersdk.ChatMessage {
}
return msg
}
func nullInt64Ptr(v sql.NullInt64) *int64 {
if !v.Valid {
return nil
}
value := v.Int64
return &value
}
+250 -9
View File
@@ -10,6 +10,7 @@ import (
"time"
"charm.land/fantasy"
fantasyanthropic "charm.land/fantasy/providers/anthropic"
"github.com/google/jsonschema-go/jsonschema"
"github.com/google/uuid"
@@ -481,17 +482,23 @@ func TestChatMessagesToPrompt_InjectsMissingToolResults(t *testing.T) {
prompt, err := chatMessagesToPrompt(messages)
require.NoError(t, err)
// Original 3 messages + synthesized assistant tool_use + tool result for call-2.
require.Len(t, prompt, 5)
// Original 3 messages + synthetic tool result for call-2.
// injectMissingToolResults sees that call-2 has no result
// and appends a synthetic error result. No extra assistant
// message is needed because the original assistant already
// contains both tool_use blocks.
require.Len(t, prompt, 4)
syntheticToolUse := prompt[3]
require.Equal(t, fantasy.MessageRoleAssistant, syntheticToolUse.Role)
syntheticCalls := extractToolCallsFromMessageParts(syntheticToolUse.Content)
require.Len(t, syntheticCalls, 1)
require.Equal(t, "call-2", syntheticCalls[0].ToolCallID)
require.Equal(t, toolReadFile, syntheticCalls[0].ToolName)
// First tool message carries the real call-1 result.
firstToolMsg := prompt[2]
require.Equal(t, fantasy.MessageRoleTool, firstToolMsg.Role)
firstParts := messageToolResultParts(firstToolMsg)
require.Len(t, firstParts, 1)
require.Equal(t, "call-1", firstParts[0].ToolCallID)
injectedMsg := prompt[4]
// Second tool message is the synthetic interrupted result
// for call-2.
injectedMsg := prompt[3]
require.Equal(t, fantasy.MessageRoleTool, injectedMsg.Role)
injectedParts := messageToolResultParts(injectedMsg)
require.Len(t, injectedParts, 1)
@@ -568,6 +575,98 @@ func TestChatMessagesToPrompt_InjectsMissingToolResults(t *testing.T) {
require.Len(t, prompt, 3, "no injection when all results present")
})
}
func TestChatMessagesToPrompt_SeparateToolResults(t *testing.T) {
t.Parallel()
// Reproduce the exact pattern from production: an assistant message
// with 6 tool calls, followed by 6 SEPARATE tool result messages
// (one per result, as the code persists them), then another assistant
// with 1 tool call and 1 tool result.
userContent, err := json.Marshal(contentFromText("analyze the repo"))
require.NoError(t, err)
// First assistant: 6 tool calls.
assistantBlocks1 := append(contentFromText("I'll read files in parallel."),
fantasy.ToolCallContent{ToolCallID: "call-1", ToolName: toolReadFile, Input: `{"path":"a.txt"}`},
fantasy.ToolCallContent{ToolCallID: "call-2", ToolName: toolReadFile, Input: `{"path":"b.txt"}`},
fantasy.ToolCallContent{ToolCallID: "call-3", ToolName: toolReadFile, Input: `{"path":"c.txt"}`},
fantasy.ToolCallContent{ToolCallID: "call-4", ToolName: toolExecute, Input: `{"command":"ls"}`},
fantasy.ToolCallContent{ToolCallID: "call-5", ToolName: toolExecute, Input: `{"command":"pwd"}`},
fantasy.ToolCallContent{ToolCallID: "call-6", ToolName: toolReadFile, Input: `{"path":"d.txt"}`},
)
assistantContent1, err := json.Marshal(assistantBlocks1)
require.NoError(t, err)
// 6 separate tool result messages (one per tool call).
makeToolResult := func(callID, toolName string, isError bool, result map[string]any) pqtype.NullRawMessage {
data, err := json.Marshal([]ToolResultBlock{{
ToolCallID: callID,
ToolName: toolName,
Result: result,
IsError: isError,
}})
require.NoError(t, err)
return pqtype.NullRawMessage{RawMessage: data, Valid: true}
}
// Second assistant: 1 tool call.
assistantBlocks2 := append(contentFromText("Let me check more."),
fantasy.ToolCallContent{ToolCallID: "call-7", ToolName: toolExecute, Input: `{"command":"cat x"}`},
)
assistantContent2, err := json.Marshal(assistantBlocks2)
require.NoError(t, err)
messages := []database.ChatMessage{
{Role: string(fantasy.MessageRoleUser), Content: pqtype.NullRawMessage{RawMessage: userContent, Valid: true}},
{Role: string(fantasy.MessageRoleAssistant), Content: pqtype.NullRawMessage{RawMessage: assistantContent1, Valid: true}},
{Role: string(fantasy.MessageRoleTool), Content: makeToolResult("call-1", toolReadFile, true, map[string]any{"error": "not found"})},
{Role: string(fantasy.MessageRoleTool), Content: makeToolResult("call-2", toolReadFile, true, map[string]any{"error": "not found"})},
{Role: string(fantasy.MessageRoleTool), Content: makeToolResult("call-3", toolReadFile, true, map[string]any{"error": "not found"})},
{Role: string(fantasy.MessageRoleTool), Content: makeToolResult("call-4", toolExecute, false, map[string]any{"output": "file1\nfile2"})},
{Role: string(fantasy.MessageRoleTool), Content: makeToolResult("call-5", toolExecute, false, map[string]any{"output": "/home"})},
{Role: string(fantasy.MessageRoleTool), Content: makeToolResult("call-6", toolReadFile, true, map[string]any{"error": "not found"})},
{Role: string(fantasy.MessageRoleAssistant), Content: pqtype.NullRawMessage{RawMessage: assistantContent2, Valid: true}},
{Role: string(fantasy.MessageRoleTool), Content: makeToolResult("call-7", toolExecute, false, map[string]any{"output": "data"})},
}
prompt, err := chatMessagesToPrompt(messages)
require.NoError(t, err)
// Should have: user, assistant(6 calls), 6 tool msgs, assistant(1 call), 1 tool msg = 10 messages.
require.Len(t, prompt, 10, "all messages should be present")
// Verify structure.
require.Equal(t, fantasy.MessageRoleUser, prompt[0].Role)
require.Equal(t, fantasy.MessageRoleAssistant, prompt[1].Role)
require.Len(t, extractToolCallsFromMessageParts(prompt[1].Content), 6)
// Check all 6 tool results are present and have correct IDs.
for i := 2; i <= 7; i++ {
require.Equal(t, fantasy.MessageRoleTool, prompt[i].Role, "prompt[%d] should be tool", i)
results := messageToolResultParts(prompt[i])
require.Len(t, results, 1, "prompt[%d] should have 1 tool result", i)
}
// Collect all tool result IDs.
answered := make(map[string]struct{})
for i := 2; i <= 7; i++ {
results := messageToolResultParts(prompt[i])
answered[results[0].ToolCallID] = struct{}{}
}
for j := 1; j <= 6; j++ {
_, ok := answered[fmt.Sprintf("call-%d", j)]
require.True(t, ok, "call-%d should have a result", j)
}
require.Equal(t, fantasy.MessageRoleAssistant, prompt[8].Role)
require.Len(t, extractToolCallsFromMessageParts(prompt[8].Content), 1)
require.Equal(t, fantasy.MessageRoleTool, prompt[9].Role)
results9 := messageToolResultParts(prompt[9])
require.Len(t, results9, 1)
require.Equal(t, "call-7", results9[0].ToolCallID)
}
func messageToolResultParts(message fantasy.Message) []fantasy.ToolResultPart {
results := make([]fantasy.ToolResultPart, 0, len(message.Content))
for _, part := range message.Content {
@@ -602,6 +701,7 @@ func TestPrepareAgentStepResult_ReportOnly(t *testing.T) {
},
sentinel,
true,
false,
)
require.Equal(t, []string{toolSubagentReport}, result.ActiveTools)
@@ -609,6 +709,93 @@ func TestPrepareAgentStepResult_ReportOnly(t *testing.T) {
require.Equal(t, fantasy.MessageRoleUser, result.Messages[0].Role)
}
func TestPrepareAgentStepResult_AnthropicCaching(t *testing.T) {
t.Parallel()
result := prepareAgentStepResult(
[]fantasy.Message{
textMessage(fantasy.MessageRoleSystem, "sys-1"),
textMessage(fantasy.MessageRoleSystem, "sys-2"),
textMessage(fantasy.MessageRoleUser, "hello"),
textMessage(fantasy.MessageRoleAssistant, "working"),
textMessage(fantasy.MessageRoleUser, "continue"),
},
"__sentinel__",
false,
true,
)
require.Len(t, result.Messages, 5)
require.False(t, hasAnthropicEphemeralCacheControl(result.Messages[0]))
require.True(t, hasAnthropicEphemeralCacheControl(result.Messages[1]))
require.False(t, hasAnthropicEphemeralCacheControl(result.Messages[2]))
require.True(t, hasAnthropicEphemeralCacheControl(result.Messages[3]))
require.True(t, hasAnthropicEphemeralCacheControl(result.Messages[4]))
}
func TestPrepareAgentStepResult_NonAnthropicUnchanged(t *testing.T) {
t.Parallel()
result := prepareAgentStepResult(
[]fantasy.Message{
textMessage(fantasy.MessageRoleSystem, "sys"),
textMessage(fantasy.MessageRoleUser, "hello"),
textMessage(fantasy.MessageRoleAssistant, "working"),
},
"__sentinel__",
false,
false,
)
require.Len(t, result.Messages, 3)
for _, message := range result.Messages {
require.Nil(t, message.ProviderOptions)
}
}
func TestPrepareAgentStepResult_AnthropicCachingWithoutSystemMessage(t *testing.T) {
t.Parallel()
result := prepareAgentStepResult(
[]fantasy.Message{
textMessage(fantasy.MessageRoleUser, "first"),
textMessage(fantasy.MessageRoleAssistant, "second"),
textMessage(fantasy.MessageRoleUser, "third"),
},
"__sentinel__",
false,
true,
)
require.Len(t, result.Messages, 3)
require.False(t, hasAnthropicEphemeralCacheControl(result.Messages[0]))
require.True(t, hasAnthropicEphemeralCacheControl(result.Messages[1]))
require.True(t, hasAnthropicEphemeralCacheControl(result.Messages[2]))
}
func textMessage(role fantasy.MessageRole, text string) fantasy.Message {
return fantasy.Message{
Role: role,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: text},
},
}
}
func hasAnthropicEphemeralCacheControl(message fantasy.Message) bool {
if len(message.ProviderOptions) == 0 {
return false
}
options, ok := message.ProviderOptions[fantasyanthropic.Name]
if !ok {
return false
}
cacheOptions, ok := options.(*fantasyanthropic.ProviderCacheControlOptions)
return ok && cacheOptions.CacheControl.Type == "ephemeral"
}
// Consolidated from schema_test.go.
func TestSchemaMap_NormalizesRequiredArrays(t *testing.T) {
t.Parallel()
@@ -795,6 +982,60 @@ func TestSDKChatMessage_ToolResultPartMetadata(t *testing.T) {
require.Equal(t, 17, *part.ResultMeta.ExitCode)
}
func TestSDKChatMessage_IncludesUsageFields(t *testing.T) {
t.Parallel()
message := SDKChatMessage(database.ChatMessage{
ID: 99,
ChatID: uuid.New(),
CreatedAt: time.Now(),
Role: string(fantasy.MessageRoleAssistant),
InputTokens: sql.NullInt64{
Int64: 101,
Valid: true,
},
OutputTokens: sql.NullInt64{
Int64: 37,
Valid: true,
},
ReasoningTokens: sql.NullInt64{
Int64: 11,
Valid: true,
},
CacheCreationTokens: sql.NullInt64{
Int64: 5,
Valid: true,
},
CacheReadTokens: sql.NullInt64{
Int64: 2,
Valid: true,
},
TotalTokens: sql.NullInt64{
Int64: 138,
Valid: true,
},
ContextLimit: sql.NullInt64{
Int64: 200000,
Valid: true,
},
})
require.NotNil(t, message.InputTokens)
require.Equal(t, int64(101), *message.InputTokens)
require.NotNil(t, message.OutputTokens)
require.Equal(t, int64(37), *message.OutputTokens)
require.NotNil(t, message.ReasoningTokens)
require.Equal(t, int64(11), *message.ReasoningTokens)
require.NotNil(t, message.CacheCreationTokens)
require.Equal(t, int64(5), *message.CacheCreationTokens)
require.NotNil(t, message.CacheReadTokens)
require.Equal(t, int64(2), *message.CacheReadTokens)
require.NotNil(t, message.TotalTokens)
require.Equal(t, int64(138), *message.TotalTokens)
require.NotNil(t, message.ContextLimit)
require.Equal(t, int64(200000), *message.ContextLimit)
}
func TestStreamManager_SnapshotBuffersOnlyMessageParts(t *testing.T) {
t.Parallel()
+201 -4
View File
@@ -85,6 +85,31 @@ type fakeModel struct {
calls int
}
type fakeContextLimitMetadata struct {
Limits fakeContextLimitMetadataLimits `json:"limits"`
}
type fakeContextLimitMetadataLimits struct {
ContextLimit int64 `json:"context_limit"`
}
func (*fakeContextLimitMetadata) Options() {}
func (m fakeContextLimitMetadata) MarshalJSON() ([]byte, error) {
type plain fakeContextLimitMetadata
return json.Marshal(plain(m))
}
func (m *fakeContextLimitMetadata) UnmarshalJSON(data []byte) error {
type plain fakeContextLimitMetadata
var decoded plain
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
*m = fakeContextLimitMetadata(decoded)
return nil
}
func (*fakeModel) Generate(context.Context, fantasy.Call) (*fantasy.Response, error) {
return fallbackResponse(), nil
}
@@ -109,8 +134,20 @@ func (f *fakeModel) Stream(_ context.Context, _ fantasy.Call) (fantasy.StreamRes
ToolCallInput: string(args),
},
{
Type: fantasy.StreamPartTypeFinish,
Usage: fantasy.Usage{InputTokens: 1, OutputTokens: 1, TotalTokens: 2},
Type: fantasy.StreamPartTypeFinish,
Usage: fantasy.Usage{
InputTokens: 11,
OutputTokens: 7,
TotalTokens: 18,
ReasoningTokens: 6,
CacheReadTokens: 3,
CacheCreationTokens: 2,
},
ProviderMetadata: fantasy.ProviderMetadata{
"fake": &fakeContextLimitMetadata{
Limits: fakeContextLimitMetadataLimits{ContextLimit: 8192},
},
},
FinishReason: fantasy.FinishReasonToolCalls,
},
}
@@ -120,8 +157,20 @@ func (f *fakeModel) Stream(_ context.Context, _ fantasy.Call) (fantasy.StreamRes
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "done"},
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"},
{
Type: fantasy.StreamPartTypeFinish,
Usage: fantasy.Usage{InputTokens: 1, OutputTokens: 1, TotalTokens: 2},
Type: fantasy.StreamPartTypeFinish,
Usage: fantasy.Usage{
InputTokens: 13,
OutputTokens: 5,
TotalTokens: 18,
ReasoningTokens: 4,
CacheReadTokens: 4,
CacheCreationTokens: 1,
},
ProviderMetadata: fantasy.ProviderMetadata{
"fake": &fakeContextLimitMetadata{
Limits: fakeContextLimitMetadataLimits{ContextLimit: 8192},
},
},
FinishReason: fantasy.FinishReasonToolCalls,
},
}
@@ -840,8 +889,32 @@ func TestRunChatLoop(t *testing.T) {
require.NoError(t, err)
require.Len(t, stored, 4)
firstAssistant := stored[1]
require.Equal(t, string(fantasy.MessageRoleAssistant), firstAssistant.Role)
require.True(t, firstAssistant.InputTokens.Valid)
require.Equal(t, int64(11), firstAssistant.InputTokens.Int64)
require.True(t, firstAssistant.OutputTokens.Valid)
require.Equal(t, int64(7), firstAssistant.OutputTokens.Int64)
require.True(t, firstAssistant.TotalTokens.Valid)
require.Equal(t, int64(18), firstAssistant.TotalTokens.Int64)
require.True(t, firstAssistant.ReasoningTokens.Valid)
require.Equal(t, int64(6), firstAssistant.ReasoningTokens.Int64)
require.True(t, firstAssistant.CacheCreationTokens.Valid)
require.Equal(t, int64(2), firstAssistant.CacheCreationTokens.Int64)
require.True(t, firstAssistant.CacheReadTokens.Valid)
require.Equal(t, int64(3), firstAssistant.CacheReadTokens.Int64)
require.True(t, firstAssistant.ContextLimit.Valid)
require.Equal(t, int64(8192), firstAssistant.ContextLimit.Int64)
toolMessage := stored[2]
require.Equal(t, string(fantasy.MessageRoleTool), toolMessage.Role)
require.False(t, toolMessage.InputTokens.Valid)
require.False(t, toolMessage.OutputTokens.Valid)
require.False(t, toolMessage.TotalTokens.Valid)
require.False(t, toolMessage.ReasoningTokens.Valid)
require.False(t, toolMessage.CacheCreationTokens.Valid)
require.False(t, toolMessage.CacheReadTokens.Valid)
require.False(t, toolMessage.ContextLimit.Valid)
var toolResults []chatd.ToolResultBlock
require.NoError(t, json.Unmarshal(toolMessage.Content.RawMessage, &toolResults))
require.Len(t, toolResults, 1)
@@ -856,6 +929,20 @@ func TestRunChatLoop(t *testing.T) {
textBlock, ok := fantasy.AsContentType[fantasy.TextContent](finalContent[0])
require.True(t, ok)
require.Equal(t, "done", textBlock.Text)
require.True(t, finalMessage.InputTokens.Valid)
require.Equal(t, int64(13), finalMessage.InputTokens.Int64)
require.True(t, finalMessage.OutputTokens.Valid)
require.Equal(t, int64(5), finalMessage.OutputTokens.Int64)
require.True(t, finalMessage.TotalTokens.Valid)
require.Equal(t, int64(18), finalMessage.TotalTokens.Int64)
require.True(t, finalMessage.ReasoningTokens.Valid)
require.Equal(t, int64(4), finalMessage.ReasoningTokens.Int64)
require.True(t, finalMessage.CacheCreationTokens.Valid)
require.Equal(t, int64(1), finalMessage.CacheCreationTokens.Int64)
require.True(t, finalMessage.CacheReadTokens.Valid)
require.Equal(t, int64(4), finalMessage.CacheReadTokens.Int64)
require.True(t, finalMessage.ContextLimit.Valid)
require.Equal(t, int64(8192), finalMessage.ContextLimit.Int64)
}
func TestRunChatLoop_ExecuteTimeoutContinues(t *testing.T) {
@@ -2053,6 +2140,116 @@ func TestRunChatLoop_DelegatedChildWithoutReportRequeuesForReportPass(t *testing
}, testutil.WaitLong, 25*time.Millisecond)
}
func TestRunChatLoop_DelegatedChildFollowUpInsertedWhileRunningRequeuesPending(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := coderdtest.NewWithDatabase(t, nil)
user := coderdtest.CreateFirstUser(t, client)
dbCtx := dbauthz.AsSystemRestricted(ctx)
parent := insertChatWithUserMessage(t, db, dbCtx, user.UserID, "parent", "parent prompt")
child, _ := insertDelegatedChildChatWithUserMessage(
t,
db,
dbCtx,
user.UserID,
parent.ID,
"child",
"run delegated subagent",
)
// Keep an active descendant so the old defer branch that only checks
// descendants does not run.
_, _ = insertDelegatedChildChatWithUserMessage(
t,
db,
dbCtx,
user.UserID,
child.ID,
"grandchild",
"pending delegated work",
)
model := &blockingCloseModel{
started: make(chan struct{}),
release: make(chan struct{}),
}
processor := chatd.NewProcessor(
testutil.Logger(t).Named("chatd"),
db,
chatd.WithModelResolver(func(_ database.Chat) (fantasy.LanguageModel, error) {
return model, nil
}),
chatd.WithPollInterval(500*time.Millisecond),
)
closed := false
defer func() {
if !closed {
_ = processor.Close()
}
}()
_, err := db.UpdateChatStatus(dbCtx, database.UpdateChatStatusParams{
ID: child.ID,
Status: database.ChatStatusPending,
WorkerID: uuid.NullUUID{},
StartedAt: sql.NullTime{},
})
require.NoError(t, err)
select {
case <-model.started:
case <-time.After(testutil.WaitLong):
t.Fatal("timed out waiting for delegated child run to start")
}
storedChild, err := db.GetChatByID(dbCtx, child.ID)
require.NoError(t, err)
require.Equal(t, database.ChatStatusRunning, storedChild.Status)
followUpRequestID := uuid.New()
followUpContent, err := json.Marshal(contentFromText("follow up while running"))
require.NoError(t, err)
_, err = db.InsertChatMessage(dbCtx, database.InsertChatMessageParams{
ChatID: child.ID,
Role: string(fantasy.MessageRoleUser),
Content: pqtype.NullRawMessage{RawMessage: followUpContent, Valid: true},
Hidden: false,
SubagentRequestID: uuid.NullUUID{
UUID: followUpRequestID,
Valid: true,
},
SubagentEvent: sql.NullString{
String: "request",
Valid: true,
},
})
require.NoError(t, err)
close(model.release)
require.Eventually(t, func() bool {
updatedChild, lookupErr := db.GetChatByID(dbCtx, child.ID)
return lookupErr == nil && updatedChild.Status == database.ChatStatusPending
}, testutil.WaitLong, 25*time.Millisecond)
pendingRequestID, err := db.GetLatestPendingSubagentRequestIDByChatID(dbCtx, child.ID)
require.NoError(t, err)
require.True(t, pendingRequestID.Valid)
require.Equal(t, followUpRequestID, pendingRequestID.UUID)
require.NoError(t, processor.Close())
closed = true
acquired, err := db.AcquireChat(dbCtx, database.AcquireChatParams{
StartedAt: time.Now(),
WorkerID: uuid.New(),
})
require.NoError(t, err)
require.Equal(t, child.ID, acquired.ID)
}
func TestRunChatLoop_ReportOnlyPassWithoutSubagentReportFallsBack(t *testing.T) {
t.Parallel()
+35 -1
View File
@@ -12,6 +12,7 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/codersdk"
)
var ErrSubagentNotDescendant = xerrors.New("target chat is not a descendant of current chat")
@@ -68,6 +69,7 @@ type SubagentService struct {
db subagentServiceStore
interrupter subagentServiceInterrupter
streamer *StreamManager
waitersMu sync.Mutex
waiters map[subagentRequestKey][]chan SubagentAwaitResult
@@ -83,6 +85,27 @@ func newSubagentService(db subagentServiceStore, interrupter subagentServiceInte
}
}
func (s *SubagentService) setStreamManager(streamer *StreamManager) {
s.streamer = streamer
}
func (s *SubagentService) publishChildStatus(chat database.Chat, status database.ChatStatus) {
if !chat.ParentChatID.Valid || chat.ParentChatID.UUID == uuid.Nil {
return
}
if s.streamer == nil {
return
}
s.streamer.Publish(chat.ParentChatID.UUID, codersdk.ChatStreamEvent{
Type: codersdk.ChatStreamEventTypeStatus,
ChatID: chat.ID,
Status: &codersdk.ChatStreamStatus{
Status: codersdk.ChatStatus(status),
},
})
}
func (s *SubagentService) CreateChildSubagentChat(
ctx context.Context,
parent database.Chat,
@@ -228,6 +251,7 @@ func (s *SubagentService) requeueChatIfNeeded(ctx context.Context, chat database
if err != nil {
return database.Chat{}, xerrors.Errorf("requeue subagent chat: %w", err)
}
s.publishChildStatus(updatedChat, database.ChatStatusPending)
return updatedChat, nil
}
@@ -248,6 +272,14 @@ func (s *SubagentService) LatestPendingRequestID(
return requestID.UUID, true, nil
}
func (s *SubagentService) HasPendingRequest(ctx context.Context, chatID uuid.UUID) (bool, error) {
_, hasPending, err := s.LatestPendingRequestID(ctx, chatID)
if err != nil {
return false, err
}
return hasPending, nil
}
func (s *SubagentService) ShouldRunReportOnlyPass(
ctx context.Context,
chatID uuid.UUID,
@@ -370,6 +402,7 @@ func (s *SubagentService) MarkSubagentReported(
s.resolveRequestWaiters(subagentRequestKey{chatID: chatID, requestID: requestID}, result)
if chat.ParentChatID.Valid {
s.publishChildStatus(chat, database.ChatStatusCompleted)
if err := s.requeueParentChat(ctx, chat.ParentChatID.UUID); err != nil {
return SubagentAwaitResult{}, err
}
@@ -562,7 +595,7 @@ func (s *SubagentService) TerminateSubagentSubtree(
}
if chat.Status == database.ChatStatusPending {
_, err := s.db.UpdateChatStatus(ctx, database.UpdateChatStatusParams{
updatedChat, err := s.db.UpdateChatStatus(ctx, database.UpdateChatStatusParams{
ID: chat.ID,
Status: database.ChatStatusWaiting,
WorkerID: uuid.NullUUID{},
@@ -571,6 +604,7 @@ func (s *SubagentService) TerminateSubagentSubtree(
if err != nil {
return xerrors.Errorf("set pending chat waiting for termination: %w", err)
}
s.publishChildStatus(updatedChat, database.ChatStatusWaiting)
}
for {
+61
View File
@@ -14,6 +14,8 @@ import (
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
func TestProcessor_SubagentToolIncludesCreatedTitle(t *testing.T) {
@@ -361,6 +363,65 @@ func TestSubagentService_MarkSubagentReportedWakesParentToPending(t *testing.T)
}
}
func TestSubagentService_CreateChildSubagentChatPublishesPendingStatusToParentStream(t *testing.T) {
t.Parallel()
parentID := uuid.New()
parent := testSubagentChat(parentID, uuid.Nil)
parent.Title = "Parent"
parent.Status = database.ChatStatusWaiting
store := newSubagentServiceTestStore(parent)
streamManager := NewStreamManager(testutil.Logger(t))
service := newSubagentService(store, nil)
service.setStreamManager(streamManager)
_, events, cancel := streamManager.Subscribe(parentID)
defer cancel()
child, _, err := service.CreateChildSubagentChat(
context.Background(),
parent,
"do delegated work",
"Child",
true,
)
require.NoError(t, err)
require.Equal(t, database.ChatStatusPending, child.Status)
require.Eventually(t, func() bool {
select {
case event, ok := <-events:
if !ok {
return false
}
if event.Type != codersdk.ChatStreamEventTypeStatus || event.Status == nil {
return false
}
if event.ChatID != child.ID {
return false
}
return event.Status.Status == codersdk.ChatStatusPending
default:
return false
}
}, testutil.WaitShort, 10*time.Millisecond)
}
func TestSubagentService_PublishChildStatusSkipsMissingParent(t *testing.T) {
t.Parallel()
store := newSubagentServiceTestStore()
streamManager := NewStreamManager(testutil.Logger(t))
service := newSubagentService(store, nil)
service.setStreamManager(streamManager)
rootChat := testSubagentChat(uuid.New(), uuid.Nil)
require.NotPanics(t, func() {
service.publishChildStatus(rootChat, database.ChatStatusRunning)
})
}
func TestSubagentService_MarkSubagentReportedDoesNotWakeRunningParent(t *testing.T) {
t.Parallel()
+82
View File
@@ -12,6 +12,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/coderdtest"
@@ -178,6 +179,87 @@ func TestChats(t *testing.T) {
require.Len(t, result.Messages, len(before.Messages)+1)
})
t.Run("GetIncludesMessageUsage", func(t *testing.T) {
t.Parallel()
client, _, api := coderdtest.NewWithAPI(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitShort)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
Message: "Test usage fields",
})
require.NoError(t, err)
_, err = api.Database.InsertChatMessage(
dbauthz.AsSystemRestricted(ctx),
database.InsertChatMessageParams{
ChatID: chat.ID,
Role: "assistant",
Content: pqtype.NullRawMessage{RawMessage: json.RawMessage(`"usage response"`), Valid: true},
Hidden: false,
InputTokens: sql.NullInt64{
Int64: 120,
Valid: true,
},
OutputTokens: sql.NullInt64{
Int64: 45,
Valid: true,
},
ReasoningTokens: sql.NullInt64{
Int64: 12,
Valid: true,
},
CacheCreationTokens: sql.NullInt64{
Int64: 3,
Valid: true,
},
CacheReadTokens: sql.NullInt64{
Int64: 9,
Valid: true,
},
TotalTokens: sql.NullInt64{
Int64: 165,
Valid: true,
},
ContextLimit: sql.NullInt64{
Int64: 200000,
Valid: true,
},
},
)
require.NoError(t, err)
result, err := client.GetChat(ctx, chat.ID)
require.NoError(t, err)
assistantMessage, ok := firstChatMessageByRole(result.Messages, "assistant")
require.True(t, ok)
require.NotNil(t, assistantMessage.InputTokens)
require.Equal(t, int64(120), *assistantMessage.InputTokens)
require.NotNil(t, assistantMessage.OutputTokens)
require.Equal(t, int64(45), *assistantMessage.OutputTokens)
require.NotNil(t, assistantMessage.ReasoningTokens)
require.Equal(t, int64(12), *assistantMessage.ReasoningTokens)
require.NotNil(t, assistantMessage.CacheCreationTokens)
require.Equal(t, int64(3), *assistantMessage.CacheCreationTokens)
require.NotNil(t, assistantMessage.CacheReadTokens)
require.Equal(t, int64(9), *assistantMessage.CacheReadTokens)
require.NotNil(t, assistantMessage.TotalTokens)
require.Equal(t, int64(165), *assistantMessage.TotalTokens)
require.NotNil(t, assistantMessage.ContextLimit)
require.Equal(t, int64(200000), *assistantMessage.ContextLimit)
userMessage, ok := firstChatMessageByRole(result.Messages, "user")
require.True(t, ok)
require.Nil(t, userMessage.InputTokens)
require.Nil(t, userMessage.OutputTokens)
require.Nil(t, userMessage.ReasoningTokens)
require.Nil(t, userMessage.CacheCreationTokens)
require.Nil(t, userMessage.CacheReadTokens)
require.Nil(t, userMessage.TotalTokens)
require.Nil(t, userMessage.ContextLimit)
})
t.Run("NotFound", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
+7
View File
@@ -1180,6 +1180,13 @@ CREATE TABLE chat_messages (
hidden boolean DEFAULT false NOT NULL,
subagent_request_id uuid,
subagent_event text,
input_tokens bigint,
output_tokens bigint,
total_tokens bigint,
reasoning_tokens bigint,
cache_creation_tokens bigint,
cache_read_tokens bigint,
context_limit bigint,
CONSTRAINT chat_messages_subagent_event_check CHECK (((subagent_event IS NULL) OR (subagent_event = ANY (ARRAY['request'::text, 'response'::text]))))
);
@@ -0,0 +1,10 @@
ALTER TABLE chat_messages
DROP COLUMN IF EXISTS context_limit,
DROP COLUMN IF EXISTS cache_read_tokens,
DROP COLUMN IF EXISTS cache_creation_tokens,
DROP COLUMN IF EXISTS reasoning_tokens,
DROP COLUMN IF EXISTS total_tokens,
DROP COLUMN IF EXISTS output_tokens,
DROP COLUMN IF EXISTS input_tokens,
DROP COLUMN IF EXISTS cached_output_tokens,
DROP COLUMN IF EXISTS cached_input_tokens;
@@ -0,0 +1,39 @@
ALTER TABLE chat_messages
ADD COLUMN IF NOT EXISTS input_tokens BIGINT,
ADD COLUMN IF NOT EXISTS output_tokens BIGINT,
ADD COLUMN IF NOT EXISTS total_tokens BIGINT,
ADD COLUMN IF NOT EXISTS reasoning_tokens BIGINT,
ADD COLUMN IF NOT EXISTS cache_creation_tokens BIGINT,
ADD COLUMN IF NOT EXISTS cache_read_tokens BIGINT,
ADD COLUMN IF NOT EXISTS context_limit BIGINT;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'chat_messages'
AND column_name = 'cached_output_tokens'
) THEN
UPDATE chat_messages
SET cache_creation_tokens = COALESCE(cache_creation_tokens, cached_output_tokens)
WHERE cached_output_tokens IS NOT NULL;
END IF;
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'chat_messages'
AND column_name = 'cached_input_tokens'
) THEN
UPDATE chat_messages
SET cache_read_tokens = COALESCE(cache_read_tokens, cached_input_tokens)
WHERE cached_input_tokens IS NOT NULL;
END IF;
END $$;
ALTER TABLE chat_messages
DROP COLUMN IF EXISTS cached_output_tokens,
DROP COLUMN IF EXISTS cached_input_tokens;
+17 -10
View File
@@ -3842,16 +3842,23 @@ type ChatDiffStatus struct {
}
type ChatMessage struct {
ID int64 `db:"id" json:"id"`
ChatID uuid.UUID `db:"chat_id" json:"chat_id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
Role string `db:"role" json:"role"`
Content pqtype.NullRawMessage `db:"content" json:"content"`
ToolCallID sql.NullString `db:"tool_call_id" json:"tool_call_id"`
Thinking sql.NullString `db:"thinking" json:"thinking"`
Hidden bool `db:"hidden" json:"hidden"`
SubagentRequestID uuid.NullUUID `db:"subagent_request_id" json:"subagent_request_id"`
SubagentEvent sql.NullString `db:"subagent_event" json:"subagent_event"`
ID int64 `db:"id" json:"id"`
ChatID uuid.UUID `db:"chat_id" json:"chat_id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
Role string `db:"role" json:"role"`
Content pqtype.NullRawMessage `db:"content" json:"content"`
ToolCallID sql.NullString `db:"tool_call_id" json:"tool_call_id"`
Thinking sql.NullString `db:"thinking" json:"thinking"`
Hidden bool `db:"hidden" json:"hidden"`
SubagentRequestID uuid.NullUUID `db:"subagent_request_id" json:"subagent_request_id"`
SubagentEvent sql.NullString `db:"subagent_event" json:"subagent_event"`
InputTokens sql.NullInt64 `db:"input_tokens" json:"input_tokens"`
OutputTokens sql.NullInt64 `db:"output_tokens" json:"output_tokens"`
TotalTokens sql.NullInt64 `db:"total_tokens" json:"total_tokens"`
ReasoningTokens sql.NullInt64 `db:"reasoning_tokens" json:"reasoning_tokens"`
CacheCreationTokens sql.NullInt64 `db:"cache_creation_tokens" json:"cache_creation_tokens"`
CacheReadTokens sql.NullInt64 `db:"cache_read_tokens" json:"cache_read_tokens"`
ContextLimit sql.NullInt64 `db:"context_limit" json:"context_limit"`
}
type ChatModelConfig struct {
+70 -14
View File
@@ -2742,7 +2742,7 @@ func (q *sqlQuerier) GetChatDiffStatusesByChatIDs(ctx context.Context, chatIds [
const getChatMessageByID = `-- name: GetChatMessageByID :one
SELECT
id, chat_id, created_at, role, content, tool_call_id, thinking, hidden, subagent_request_id, subagent_event
id, chat_id, created_at, role, content, tool_call_id, thinking, hidden, subagent_request_id, subagent_event, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit
FROM
chat_messages
WHERE
@@ -2763,13 +2763,20 @@ func (q *sqlQuerier) GetChatMessageByID(ctx context.Context, id int64) (ChatMess
&i.Hidden,
&i.SubagentRequestID,
&i.SubagentEvent,
&i.InputTokens,
&i.OutputTokens,
&i.TotalTokens,
&i.ReasoningTokens,
&i.CacheCreationTokens,
&i.CacheReadTokens,
&i.ContextLimit,
)
return i, err
}
const getChatMessagesByChatID = `-- name: GetChatMessagesByChatID :many
SELECT
id, chat_id, created_at, role, content, tool_call_id, thinking, hidden, subagent_request_id, subagent_event
id, chat_id, created_at, role, content, tool_call_id, thinking, hidden, subagent_request_id, subagent_event, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit
FROM
chat_messages
WHERE
@@ -2798,6 +2805,13 @@ func (q *sqlQuerier) GetChatMessagesByChatID(ctx context.Context, chatID uuid.UU
&i.Hidden,
&i.SubagentRequestID,
&i.SubagentEvent,
&i.InputTokens,
&i.OutputTokens,
&i.TotalTokens,
&i.ReasoningTokens,
&i.CacheCreationTokens,
&i.CacheReadTokens,
&i.ContextLimit,
); err != nil {
return nil, err
}
@@ -2999,7 +3013,7 @@ func (q *sqlQuerier) GetSubagentRequestDurationByChatIDAndRequestID(ctx context.
const getSubagentResponseMessageByChatIDAndRequestID = `-- name: GetSubagentResponseMessageByChatIDAndRequestID :one
SELECT
id, chat_id, created_at, role, content, tool_call_id, thinking, hidden, subagent_request_id, subagent_event
id, chat_id, created_at, role, content, tool_call_id, thinking, hidden, subagent_request_id, subagent_event, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit
FROM
chat_messages
WHERE
@@ -3031,6 +3045,13 @@ func (q *sqlQuerier) GetSubagentResponseMessageByChatIDAndRequestID(ctx context.
&i.Hidden,
&i.SubagentRequestID,
&i.SubagentEvent,
&i.InputTokens,
&i.OutputTokens,
&i.TotalTokens,
&i.ReasoningTokens,
&i.CacheCreationTokens,
&i.CacheReadTokens,
&i.ContextLimit,
)
return i, err
}
@@ -3105,7 +3126,14 @@ INSERT INTO chat_messages (
thinking,
hidden,
subagent_request_id,
subagent_event
subagent_event,
input_tokens,
output_tokens,
total_tokens,
reasoning_tokens,
cache_creation_tokens,
cache_read_tokens,
context_limit
) VALUES (
$1::uuid,
$2::text,
@@ -3114,21 +3142,35 @@ INSERT INTO chat_messages (
$5::text,
$6::boolean,
$7::uuid,
$8::text
$8::text,
$9::bigint,
$10::bigint,
$11::bigint,
$12::bigint,
$13::bigint,
$14::bigint,
$15::bigint
)
RETURNING
id, chat_id, created_at, role, content, tool_call_id, thinking, hidden, subagent_request_id, subagent_event
id, chat_id, created_at, role, content, tool_call_id, thinking, hidden, subagent_request_id, subagent_event, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit
`
type InsertChatMessageParams struct {
ChatID uuid.UUID `db:"chat_id" json:"chat_id"`
Role string `db:"role" json:"role"`
Content pqtype.NullRawMessage `db:"content" json:"content"`
ToolCallID sql.NullString `db:"tool_call_id" json:"tool_call_id"`
Thinking sql.NullString `db:"thinking" json:"thinking"`
Hidden bool `db:"hidden" json:"hidden"`
SubagentRequestID uuid.NullUUID `db:"subagent_request_id" json:"subagent_request_id"`
SubagentEvent sql.NullString `db:"subagent_event" json:"subagent_event"`
ChatID uuid.UUID `db:"chat_id" json:"chat_id"`
Role string `db:"role" json:"role"`
Content pqtype.NullRawMessage `db:"content" json:"content"`
ToolCallID sql.NullString `db:"tool_call_id" json:"tool_call_id"`
Thinking sql.NullString `db:"thinking" json:"thinking"`
Hidden bool `db:"hidden" json:"hidden"`
SubagentRequestID uuid.NullUUID `db:"subagent_request_id" json:"subagent_request_id"`
SubagentEvent sql.NullString `db:"subagent_event" json:"subagent_event"`
InputTokens sql.NullInt64 `db:"input_tokens" json:"input_tokens"`
OutputTokens sql.NullInt64 `db:"output_tokens" json:"output_tokens"`
TotalTokens sql.NullInt64 `db:"total_tokens" json:"total_tokens"`
ReasoningTokens sql.NullInt64 `db:"reasoning_tokens" json:"reasoning_tokens"`
CacheCreationTokens sql.NullInt64 `db:"cache_creation_tokens" json:"cache_creation_tokens"`
CacheReadTokens sql.NullInt64 `db:"cache_read_tokens" json:"cache_read_tokens"`
ContextLimit sql.NullInt64 `db:"context_limit" json:"context_limit"`
}
func (q *sqlQuerier) InsertChatMessage(ctx context.Context, arg InsertChatMessageParams) (ChatMessage, error) {
@@ -3141,6 +3183,13 @@ func (q *sqlQuerier) InsertChatMessage(ctx context.Context, arg InsertChatMessag
arg.Hidden,
arg.SubagentRequestID,
arg.SubagentEvent,
arg.InputTokens,
arg.OutputTokens,
arg.TotalTokens,
arg.ReasoningTokens,
arg.CacheCreationTokens,
arg.CacheReadTokens,
arg.ContextLimit,
)
var i ChatMessage
err := row.Scan(
@@ -3154,6 +3203,13 @@ func (q *sqlQuerier) InsertChatMessage(ctx context.Context, arg InsertChatMessag
&i.Hidden,
&i.SubagentRequestID,
&i.SubagentEvent,
&i.InputTokens,
&i.OutputTokens,
&i.TotalTokens,
&i.ReasoningTokens,
&i.CacheCreationTokens,
&i.CacheReadTokens,
&i.ContextLimit,
)
return i, err
}
+16 -2
View File
@@ -96,7 +96,14 @@ INSERT INTO chat_messages (
thinking,
hidden,
subagent_request_id,
subagent_event
subagent_event,
input_tokens,
output_tokens,
total_tokens,
reasoning_tokens,
cache_creation_tokens,
cache_read_tokens,
context_limit
) VALUES (
@chat_id::uuid,
@role::text,
@@ -105,7 +112,14 @@ INSERT INTO chat_messages (
sqlc.narg('thinking')::text,
@hidden::boolean,
sqlc.narg('subagent_request_id')::uuid,
sqlc.narg('subagent_event')::text
sqlc.narg('subagent_event')::text,
sqlc.narg('input_tokens')::bigint,
sqlc.narg('output_tokens')::bigint,
sqlc.narg('total_tokens')::bigint,
sqlc.narg('reasoning_tokens')::bigint,
sqlc.narg('cache_creation_tokens')::bigint,
sqlc.narg('cache_read_tokens')::bigint,
sqlc.narg('context_limit')::bigint
)
RETURNING
*;
+28 -21
View File
@@ -24,31 +24,38 @@ const (
// Chat represents a chat session with an AI agent.
type Chat struct {
ID uuid.UUID `json:"id" format:"uuid"`
OwnerID uuid.UUID `json:"owner_id" format:"uuid"`
WorkspaceID *uuid.UUID `json:"workspace_id,omitempty" format:"uuid"`
WorkspaceAgentID *uuid.UUID `json:"workspace_agent_id,omitempty" format:"uuid"`
ParentChatID *uuid.UUID `json:"parent_chat_id,omitempty" format:"uuid"`
RootChatID *uuid.UUID `json:"root_chat_id,omitempty" format:"uuid"`
Title string `json:"title"`
Status ChatStatus `json:"status"`
DiffStatus *ChatDiffStatus `json:"diff_status,omitempty"`
ModelConfig json.RawMessage `json:"model_config,omitempty"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
ID uuid.UUID `json:"id" format:"uuid"`
OwnerID uuid.UUID `json:"owner_id" format:"uuid"`
WorkspaceID *uuid.UUID `json:"workspace_id,omitempty" format:"uuid"`
WorkspaceAgentID *uuid.UUID `json:"workspace_agent_id,omitempty" format:"uuid"`
ParentChatID *uuid.UUID `json:"parent_chat_id,omitempty" format:"uuid"`
RootChatID *uuid.UUID `json:"root_chat_id,omitempty" format:"uuid"`
Title string `json:"title"`
Status ChatStatus `json:"status"`
DiffStatus *ChatDiffStatus `json:"diff_status,omitempty"`
ModelConfig json.RawMessage `json:"model_config,omitempty"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
}
// ChatMessage represents a single message in a chat.
type ChatMessage struct {
ID int64 `json:"id"`
ChatID uuid.UUID `json:"chat_id" format:"uuid"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
Role string `json:"role"`
Content json.RawMessage `json:"content,omitempty"`
Parts []ChatMessagePart `json:"parts,omitempty"`
ToolCallID *string `json:"tool_call_id,omitempty"`
Thinking *string `json:"thinking,omitempty"`
Hidden bool `json:"hidden"`
ID int64 `json:"id"`
ChatID uuid.UUID `json:"chat_id" format:"uuid"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
Role string `json:"role"`
Content json.RawMessage `json:"content,omitempty"`
Parts []ChatMessagePart `json:"parts,omitempty"`
ToolCallID *string `json:"tool_call_id,omitempty"`
Thinking *string `json:"thinking,omitempty"`
Hidden bool `json:"hidden"`
InputTokens *int64 `json:"input_tokens,omitempty"`
OutputTokens *int64 `json:"output_tokens,omitempty"`
TotalTokens *int64 `json:"total_tokens,omitempty"`
ReasoningTokens *int64 `json:"reasoning_tokens,omitempty"`
CacheCreationTokens *int64 `json:"cache_creation_tokens,omitempty"`
CacheReadTokens *int64 `json:"cache_read_tokens,omitempty"`
ContextLimit *int64 `json:"context_limit,omitempty"`
}
// ChatMessagePartType represents a structured message part type.
+2172 -2119
View File
File diff suppressed because it is too large Load Diff
+62 -41
View File
@@ -274,13 +274,18 @@ curl -X GET http://coder-server:8080/api/v2/chats/{chat} \
},
"messages": [
{
"cache_creation_tokens": 0,
"cache_read_tokens": 0,
"chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86",
"content": [
0
],
"context_limit": 0,
"created_at": "2019-08-24T14:15:22Z",
"hidden": true,
"id": 0,
"input_tokens": 0,
"output_tokens": 0,
"parts": [
{
"args": [
@@ -319,9 +324,11 @@ curl -X GET http://coder-server:8080/api/v2/chats/{chat} \
"url": "string"
}
],
"reasoning_tokens": 0,
"role": "string",
"thinking": "string",
"tool_call_id": "string"
"tool_call_id": "string",
"total_tokens": 0
}
]
}
@@ -549,13 +556,18 @@ curl -X POST http://coder-server:8080/api/v2/chats/{chat}/messages \
```json
[
{
"cache_creation_tokens": 0,
"cache_read_tokens": 0,
"chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86",
"content": [
0
],
"context_limit": 0,
"created_at": "2019-08-24T14:15:22Z",
"hidden": true,
"id": 0,
"input_tokens": 0,
"output_tokens": 0,
"parts": [
{
"args": [
@@ -594,9 +606,11 @@ curl -X POST http://coder-server:8080/api/v2/chats/{chat}/messages \
"url": "string"
}
],
"reasoning_tokens": 0,
"role": "string",
"thinking": "string",
"tool_call_id": "string"
"tool_call_id": "string",
"total_tokens": 0
}
]
```
@@ -611,45 +625,52 @@ curl -X POST http://coder-server:8080/api/v2/chats/{chat}/messages \
Status Code **200**
| Name | Type | Required | Restrictions | Description |
|--------------------------|------------------------------------------------------------------------------|----------|--------------|-------------|
| `[array item]` | array | false | | |
| `» chat_id` | string(uuid) | false | | |
| `» content` | array | false | | |
| `» created_at` | string(date-time) | false | | |
| `» hidden` | boolean | false | | |
| `» id` | integer | false | | |
| `» parts` | array | false | | |
| `»» args` | array | false | | |
| `»» args_delta` | string | false | | |
| `»» data` | array | false | | |
| `»» is_error` | boolean | false | | |
| `»» media_type` | string | false | | |
| `»» result` | array | false | | |
| `»» result_delta` | string | false | | |
| `»» result_meta` | [codersdk.ChatToolResultMetadata](schemas.md#codersdkchattoolresultmetadata) | false | | |
| `»»» content` | string | false | | |
| `»»» created` | boolean | false | | |
| `»»» error` | string | false | | |
| `»»» exit_code` | integer | false | | |
| `»»» mime_type` | string | false | | |
| `»»» output` | string | false | | |
| `»»» reason` | string | false | | |
| `»»» workspace_agent_id` | string | false | | |
| `»»» workspace_id` | string | false | | |
| `»»» workspace_name` | string | false | | |
| `»»» workspace_url` | string | false | | |
| `»» signature` | string | false | | |
| `»» source_id` | string | false | | |
| `»» text` | string | false | | |
| `»» title` | string | false | | |
| `»» tool_call_id` | string | false | | |
| `»» tool_name` | string | false | | |
| `»» type` | [codersdk.ChatMessagePartType](schemas.md#codersdkchatmessageparttype) | false | | |
| `»» url` | string | false | | |
| `» role` | string | false | | |
| `» thinking` | string | false | | |
| `» tool_call_id` | string | false | | |
| Name | Type | Required | Restrictions | Description |
|---------------------------|------------------------------------------------------------------------------|----------|--------------|-------------|
| `[array item]` | array | false | | |
| `» cache_creation_tokens` | integer | false | | |
| `» cache_read_tokens` | integer | false | | |
| `» chat_id` | string(uuid) | false | | |
| `» content` | array | false | | |
| `» context_limit` | integer | false | | |
| `» created_at` | string(date-time) | false | | |
| `» hidden` | boolean | false | | |
| `» id` | integer | false | | |
| `» input_tokens` | integer | false | | |
| `» output_tokens` | integer | false | | |
| `» parts` | array | false | | |
| `»» args` | array | false | | |
| `»» args_delta` | string | false | | |
| `»» data` | array | false | | |
| `»» is_error` | boolean | false | | |
| `»» media_type` | string | false | | |
| `»» result` | array | false | | |
| `»» result_delta` | string | false | | |
| `»» result_meta` | [codersdk.ChatToolResultMetadata](schemas.md#codersdkchattoolresultmetadata) | false | | |
| `»»» content` | string | false | | |
| `»»» created` | boolean | false | | |
| `»»» error` | string | false | | |
| `»»» exit_code` | integer | false | | |
| `»»» mime_type` | string | false | | |
| `»»» output` | string | false | | |
| `»»» reason` | string | false | | |
| `»»» workspace_agent_id` | string | false | | |
| `»»» workspace_id` | string | false | | |
| `»»» workspace_name` | string | false | | |
| `»»» workspace_url` | string | false | | |
| `»» signature` | string | false | | |
| `»» source_id` | string | false | | |
| `»» text` | string | false | | |
| `»» title` | string | false | | |
| `»» tool_call_id` | string | false | | |
| `»» tool_name` | string | false | | |
| `»» type` | [codersdk.ChatMessagePartType](schemas.md#codersdkchatmessageparttype) | false | | |
| `»» url` | string | false | | |
| `» reasoning_tokens` | integer | false | | |
| `» role` | string | false | | |
| `» thinking` | string | false | | |
| `» tool_call_id` | string | false | | |
| `» total_tokens` | integer | false | | |
#### Enumerated Values
+34 -13
View File
@@ -1734,13 +1734,18 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
```json
{
"cache_creation_tokens": 0,
"cache_read_tokens": 0,
"chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86",
"content": [
0
],
"context_limit": 0,
"created_at": "2019-08-24T14:15:22Z",
"hidden": true,
"id": 0,
"input_tokens": 0,
"output_tokens": 0,
"parts": [
{
"args": [
@@ -1779,25 +1784,34 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
"url": "string"
}
],
"reasoning_tokens": 0,
"role": "string",
"thinking": "string",
"tool_call_id": "string"
"tool_call_id": "string",
"total_tokens": 0
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|----------------|---------------------------------------------------------------|----------|--------------|-------------|
| `chat_id` | string | false | | |
| `content` | array of integer | false | | |
| `created_at` | string | false | | |
| `hidden` | boolean | false | | |
| `id` | integer | false | | |
| `parts` | array of [codersdk.ChatMessagePart](#codersdkchatmessagepart) | false | | |
| `role` | string | false | | |
| `thinking` | string | false | | |
| `tool_call_id` | string | false | | |
| Name | Type | Required | Restrictions | Description |
|-------------------------|---------------------------------------------------------------|----------|--------------|-------------|
| `cache_creation_tokens` | integer | false | | |
| `cache_read_tokens` | integer | false | | |
| `chat_id` | string | false | | |
| `content` | array of integer | false | | |
| `context_limit` | integer | false | | |
| `created_at` | string | false | | |
| `hidden` | boolean | false | | |
| `id` | integer | false | | |
| `input_tokens` | integer | false | | |
| `output_tokens` | integer | false | | |
| `parts` | array of [codersdk.ChatMessagePart](#codersdkchatmessagepart) | false | | |
| `reasoning_tokens` | integer | false | | |
| `role` | string | false | | |
| `thinking` | string | false | | |
| `tool_call_id` | string | false | | |
| `total_tokens` | integer | false | | |
## codersdk.ChatMessagePart
@@ -2044,13 +2058,18 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
},
"messages": [
{
"cache_creation_tokens": 0,
"cache_read_tokens": 0,
"chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86",
"content": [
0
],
"context_limit": 0,
"created_at": "2019-08-24T14:15:22Z",
"hidden": true,
"id": 0,
"input_tokens": 0,
"output_tokens": 0,
"parts": [
{
"args": [
@@ -2089,9 +2108,11 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
"url": "string"
}
],
"reasoning_tokens": 0,
"role": "string",
"thinking": "string",
"tool_call_id": "string"
"tool_call_id": "string",
"total_tokens": 0
}
]
}
+3370 -4534
View File
File diff suppressed because it is too large Load Diff
+35 -14
View File
@@ -34,26 +34,44 @@ describe(Tool.name, () => {
result: { chat_id: "child-chat-id", status: "pending" },
});
expect(screen.getByRole("link", { name: "Sub-agent" })).toHaveAttribute(
expect(screen.getByRole("link", { name: "View agent" })).toHaveAttribute(
"href",
"/agents/child-chat-id",
);
},
);
it("uses tool result status for delegated status rendering", () => {
it("maps pending delegated status to running rendering", () => {
const { container } = renderTool({
name: "subagent",
result: { chat_id: "child-chat-id", status: "completed" },
result: { chat_id: "child-chat-id", status: "pending" },
status: "completed",
});
expect(screen.getByRole("link", { name: "Sub-agent" })).toHaveAttribute(
expect(screen.getByRole("link", { name: "View agent" })).toHaveAttribute(
"href",
"/agents/child-chat-id",
);
expect(
screen.getByRole("button", { name: /Spawning Sub-agent/ }),
).toBeInTheDocument();
expect(container.querySelector(".animate-spin")).toBeInTheDocument();
});
it("uses stream override status for delegated status rendering", () => {
const { container } = renderTool({
name: "subagent",
result: { chat_id: "child-chat-id", status: "pending" },
status: "completed",
subagentStatusOverrides: new Map([
["child-chat-id", "completed"],
]),
});
expect(
screen.getByRole("button", { name: /Spawned Sub-agent/ }),
).toBeInTheDocument();
expect(container.querySelector(".animate-spin")).toBeNull();
expect(container.querySelector(".lucide-check")).toBeInTheDocument();
});
it("does not show a subagent error icon when completed despite parser noise", () => {
@@ -70,7 +88,9 @@ describe(Tool.name, () => {
expect(container.querySelector(".animate-spin")).toBeNull();
expect(container.querySelector(".lucide-circle-alert")).toBeNull();
expect(container.querySelector(".lucide-check")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Spawned Sub-agent/ }),
).toBeInTheDocument();
});
it("prefers returned subagent title for await tools", () => {
@@ -84,9 +104,11 @@ describe(Tool.name, () => {
},
});
expect(
screen.getByRole("link", { name: "Delegated child title" }),
).toHaveAttribute("href", "/agents/child-chat-id");
expect(screen.getByText("Delegated child title")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "View agent" })).toHaveAttribute(
"href",
"/agents/child-chat-id",
);
expect(screen.queryByText("Fallback title")).toBeNull();
});
@@ -103,9 +125,7 @@ describe(Tool.name, () => {
},
});
expect(screen.getByText("Request ID")).toBeInTheDocument();
expect(screen.getByText("request-123")).toBeInTheDocument();
expect(screen.getByText("Duration 1.5s")).toBeInTheDocument();
expect(screen.getByText("Worked for 2s")).toBeInTheDocument();
},
);
@@ -125,7 +145,8 @@ describe(Tool.name, () => {
name: "subagent_terminate",
});
expect(screen.getByText("Terminated sub-agent")).toBeInTheDocument();
expect(screen.getByText(/Terminated/)).toBeInTheDocument();
expect(screen.getByText("Sub-agent")).toBeInTheDocument();
});
it("does not use task-specific delegated rendering", () => {
@@ -134,6 +155,6 @@ describe(Tool.name, () => {
});
expect(screen.getByText("task")).toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Sub-agent" })).toBeNull();
expect(screen.queryByRole("link", { name: "View agent" })).toBeNull();
});
});
+100 -22
View File
@@ -36,6 +36,10 @@ interface ToolProps
args?: unknown;
result?: unknown;
isError?: boolean;
/** Maps sub-agent chat IDs to their titles, built from spawn tool results. */
subagentTitles?: Map<string, string>;
/** Maps sub-agent chat IDs to real-time status updates from stream events. */
subagentStatusOverrides?: Map<string, string>;
}
const asRecord = (value: unknown): Record<string, unknown> | null => {
@@ -105,6 +109,31 @@ const isSubagentRunningStatus = (status: string): boolean => {
}
};
const mapSubagentStatusToToolStatus = (
subagentStatus: string,
fallback: ToolStatus,
): ToolStatus => {
const normalized = normalizeStatus(subagentStatus);
if (!normalized) {
return fallback;
}
if (isSubagentSuccessStatus(normalized)) {
return "completed";
}
if (isSubagentRunningStatus(normalized)) {
return "running";
}
switch (normalized) {
case "waiting":
case "terminated":
return "completed";
case "error":
return "error";
default:
return fallback;
}
};
const parseArgs = (args: unknown): Record<string, unknown> | null => {
if (!args) {
return null;
@@ -253,34 +282,43 @@ const ToolLabel: React.FC<{ name: string; args: unknown; result: unknown }> = ({
case "subagent_terminate":
return (
<span className="truncate text-sm text-content-secondary">
Terminated sub-agent
Terminating sub-agent
</span>
);
case "subagent": {
const parsed = parseArgs(args);
const title = parsed ? asString(parsed.title) : "";
const spawnTitle =
(parsedResult ? asString(parsedResult.title) : "") ||
(parsed ? asString(parsed.title) : "");
return (
<span className="truncate text-sm text-content-secondary">
{title || "Delegating to sub-agent…"}
{spawnTitle ? `Spawning ${spawnTitle}` : "Spawning sub-agent…"}
</span>
);
}
case "subagent_await":
case "subagent_await": {
const awaitTitle =
(parsedResult ? asString(parsedResult.title) : "") ||
(parsed ? asString(parsed.title) : "");
return (
<span className="truncate text-sm text-content-secondary">
Awaiting sub-agent…
{awaitTitle ? `Waiting for ${awaitTitle}` : "Waiting for sub-agent…"}
</span>
);
case "subagent_message":
}
case "subagent_message": {
const msgTitle =
(parsedResult ? asString(parsedResult.title) : "") ||
(parsed ? asString(parsed.title) : "");
return (
<span className="truncate text-sm text-content-secondary">
Messaging sub-agent…
{msgTitle ? `Messaging ${msgTitle}` : "Messaging sub-agent…"}
</span>
);
}
case "subagent_report":
return (
<span className="truncate text-sm text-content-secondary">
Sub-agent report
Reporting to parent
</span>
);
default:
@@ -1046,6 +1084,7 @@ const SubagentTool: React.FC<{
chatId: string;
subagentStatus: string;
prompt?: string;
message?: string;
durationMs?: number;
report?: string;
toolStatus: ToolStatus;
@@ -1056,6 +1095,7 @@ const SubagentTool: React.FC<{
chatId,
subagentStatus,
prompt,
message,
durationMs,
report,
toolStatus,
@@ -1063,8 +1103,9 @@ const SubagentTool: React.FC<{
}) => {
const [expanded, setExpanded] = useState(false);
const hasPrompt = Boolean(prompt?.trim());
const hasMessage = Boolean(message?.trim());
const hasReport = Boolean(report?.trim());
const hasExpandableContent = hasPrompt || hasReport;
const hasExpandableContent = hasPrompt || hasMessage || hasReport;
const durationLabel = shortDurationMs(durationMs);
return (
@@ -1092,11 +1133,23 @@ const SubagentTool: React.FC<{
isError={isError}
/>
<span className="min-w-0 flex-1 truncate text-sm text-content-secondary">
{toolName === "subagent" && toolStatus === "completed"
? "Spawned "
: toolName === "subagent_await" && toolStatus === "completed"
? "Waited for "
: ""}
{toolName === "subagent"
? toolStatus === "completed"
? "Spawned "
: "Spawning "
: toolName === "subagent_await"
? toolStatus === "completed"
? "Waited for "
: "Waiting for "
: toolName === "subagent_message"
? toolStatus === "completed"
? "Messaged "
: "Messaging "
: toolName === "subagent_terminate"
? toolStatus === "completed"
? "Terminated "
: "Terminating "
: ""}
<span className="text-content-secondary opacity-60">{title}</span>
{chatId && (
<Link
@@ -1136,6 +1189,18 @@ const SubagentTool: React.FC<{
</ScrollArea>
)}
{expanded && hasMessage && (
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<div className="px-3 py-2">
<Response>{message ?? ""}</Response>
</div>
</ScrollArea>
)}
{expanded && hasReport && (
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default"
@@ -1250,6 +1315,8 @@ export const Tool = forwardRef<HTMLDivElement, ToolProps>(
args,
result,
isError = false,
subagentTitles,
subagentStatusOverrides,
...props
},
ref,
@@ -1369,7 +1436,8 @@ export const Tool = forwardRef<HTMLDivElement, ToolProps>(
if (
name === "subagent" ||
name === "subagent_await" ||
name === "subagent_message"
name === "subagent_message" ||
name === "subagent_terminate"
) {
const parsed = parseArgs(args);
const rec = asRecord(result);
@@ -1378,21 +1446,29 @@ export const Tool = forwardRef<HTMLDivElement, ToolProps>(
const chatId =
(rec ? asString(rec.chat_id) : "") ||
(parsed ? asString(parsed.chat_id) : "");
const subagentStatus = rec ? asString(rec.status || rec.subagent_status) : "";
const resultSubagentStatus = rec
? asString(rec.status || rec.subagent_status)
: "";
const streamSubagentStatus =
(chatId && subagentStatusOverrides?.get(chatId)) || "";
const subagentStatus = streamSubagentStatus || resultSubagentStatus;
const durationMs = rec ? asNumber(rec.duration_ms) : undefined;
const report = rec ? asString(rec.report) : "";
const prompt = parsed ? asString(parsed.prompt) : "";
const subagentMessage = parsed ? asString(parsed.message) : "";
const title =
(rec ? asString(rec.title) : "") ||
(parsed ? asString(parsed.title) : "") ||
(chatId && subagentTitles?.get(chatId)) ||
"Sub-agent";
const subagentToolStatus: ToolStatus = isSubagentSuccessStatus(
const subagentCompleted = isSubagentSuccessStatus(subagentStatus);
const subagentToolStatus = mapSubagentStatusToToolStatus(
subagentStatus,
)
? "completed"
: status;
status,
);
const subagentIsError =
(status === "error" || isError) && !isSubagentSuccessStatus(subagentStatus);
subagentToolStatus === "error" ||
((status === "error" || isError) && !subagentCompleted);
if (chatId) {
return (
@@ -1403,6 +1479,7 @@ export const Tool = forwardRef<HTMLDivElement, ToolProps>(
chatId={chatId}
subagentStatus={subagentStatus}
prompt={prompt || undefined}
message={subagentMessage || undefined}
durationMs={durationMs}
report={report || undefined}
toolStatus={subagentToolStatus}
@@ -1424,6 +1501,7 @@ export const Tool = forwardRef<HTMLDivElement, ToolProps>(
chatId=""
subagentStatus={subagentStatus}
prompt={prompt || undefined}
message={subagentMessage || undefined}
toolStatus={subagentToolStatus}
isError={subagentIsError}
/>
@@ -3,11 +3,26 @@ import {
type ModelSelectorOption,
} from "components/ai-elements";
import { Button } from "components/Button/Button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { ArrowUpIcon, Loader2Icon, Square } from "lucide-react";
import { memo, type ReactNode, useCallback, useState } from "react";
import TextareaAutosize from "react-textarea-autosize";
import { formatProviderLabel } from "./modelOptions";
export interface AgentContextUsage {
readonly usedTokens?: number;
readonly contextLimitTokens?: number;
readonly inputTokens?: number;
readonly outputTokens?: number;
readonly cacheReadTokens?: number;
readonly cacheCreationTokens?: number;
readonly reasoningTokens?: number;
}
interface AgentChatInputProps {
onSend: (message: string) => Promise<void>;
placeholder?: string;
@@ -35,11 +50,162 @@ interface AgentChatInputProps {
// Extra controls rendered in the left action area (e.g. workspace
// selector on the create page).
leftActions?: ReactNode;
// Optional context-usage summary shown to the left of the send button.
// Pass `null` to render fallback values (e.g. when limit is unknown).
// Omit entirely to hide the indicator.
contextUsage?: AgentContextUsage | null;
// When true the entire input sticks to the bottom of the scroll
// container (used in the detail page).
sticky?: boolean;
}
const hasFiniteTokenValue = (value: number | undefined): value is number =>
typeof value === "number" && Number.isFinite(value) && value >= 0;
const formatTokenCount = (value: number | undefined): string =>
hasFiniteTokenValue(value) ? value.toLocaleString() : "--";
const getIndicatorToneClassName = (percentUsed: number | null): string => {
if (percentUsed === null) {
return "text-content-secondary/60";
}
if (percentUsed >= 95) {
return "text-content-destructive";
}
if (percentUsed >= 85) {
return "text-content-warning";
}
return "text-content-link";
};
const RING_SIZE = 30;
const RING_STROKE = 3;
const RING_RADIUS = (RING_SIZE - RING_STROKE) / 2;
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
const ContextUsageIndicator = memo<{ usage: AgentContextUsage | null }>(
({ usage }) => {
const usedTokens = hasFiniteTokenValue(usage?.usedTokens)
? usage.usedTokens
: undefined;
const contextLimitTokens = hasFiniteTokenValue(usage?.contextLimitTokens)
? usage.contextLimitTokens
: undefined;
const percentUsed =
usedTokens !== undefined &&
contextLimitTokens !== undefined &&
contextLimitTokens > 0
? (usedTokens / contextLimitTokens) * 100
: null;
const percentLabel =
percentUsed === null ? "--" : `${Math.round(percentUsed)}%`;
const indicatorLabel =
percentUsed === null
? "--"
: percentUsed >= 100
? "100+"
: `${Math.round(percentUsed)}`;
const clampedPercent =
percentUsed === null ? 0 : Math.min(Math.max(percentUsed, 0), 100);
const dashOffset =
RING_CIRCUMFERENCE - (clampedPercent / 100) * RING_CIRCUMFERENCE;
const toneClassName = getIndicatorToneClassName(percentUsed);
const breakdown = [
{ key: "input", label: "Input", value: usage?.inputTokens },
{ key: "output", label: "Output", value: usage?.outputTokens },
{ key: "cache-read", label: "Cache read", value: usage?.cacheReadTokens },
{
key: "cache-create",
label: "Cache creation",
value: usage?.cacheCreationTokens,
},
{ key: "reasoning", label: "Reasoning", value: usage?.reasoningTokens },
].filter(
(entry): entry is { key: string; label: string; value: number } =>
hasFiniteTokenValue(entry.value),
);
const ariaLabel = `Context usage ${percentLabel}. ${formatTokenCount(usedTokens)} of ${formatTokenCount(contextLimitTokens)} tokens used.`;
return (
<Tooltip>
<TooltipTrigger asChild>
<span
tabIndex={0}
aria-label={ariaLabel}
className="relative inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full outline-none transition-colors hover:bg-surface-secondary/60 focus-visible:ring-2 focus-visible:ring-content-link/40"
>
<svg
className={`h-8 w-8 -rotate-90 ${toneClassName}`}
viewBox={`0 0 ${RING_SIZE} ${RING_SIZE}`}
aria-hidden
>
<circle
cx={RING_SIZE / 2}
cy={RING_SIZE / 2}
r={RING_RADIUS}
fill="none"
strokeWidth={RING_STROKE}
className="stroke-border-default/70"
/>
<circle
cx={RING_SIZE / 2}
cy={RING_SIZE / 2}
r={RING_RADIUS}
fill="none"
strokeWidth={RING_STROKE}
strokeLinecap="round"
className="stroke-current transition-all duration-300 ease-out"
style={{
strokeDasharray: `${RING_CIRCUMFERENCE} ${RING_CIRCUMFERENCE}`,
strokeDashoffset: dashOffset,
}}
/>
</svg>
<span className="pointer-events-none absolute text-[9px] font-medium tabular-nums text-content-secondary">
{indicatorLabel}
</span>
</span>
</TooltipTrigger>
<TooltipContent
side="top"
className="min-w-[14rem] max-w-[16rem] border-border-default/80 bg-surface-primary/95 px-2.5 py-2"
>
<div className="space-y-1">
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-medium text-content-primary">
Context usage
</span>
<span className="font-mono text-xs tabular-nums text-content-primary">
{percentLabel}
</span>
</div>
<div className="font-mono text-[11px] tabular-nums text-content-secondary">
{formatTokenCount(usedTokens)} / {formatTokenCount(contextLimitTokens)}{" "}
tokens
</div>
{breakdown.length > 0 && (
<div className="space-y-0.5 border-t border-border-default/60 pt-1">
{breakdown.map((entry) => (
<div
key={entry.key}
className="flex items-center justify-between gap-2 text-[11px]"
>
<span className="text-content-secondary">{entry.label}</span>
<span className="font-mono tabular-nums text-content-primary">
{formatTokenCount(entry.value)}
</span>
</div>
))}
</div>
)}
</div>
</TooltipContent>
</Tooltip>
);
},
);
ContextUsageIndicator.displayName = "ContextUsageIndicator";
export const AgentChatInput = memo<AgentChatInputProps>(
({
onSend,
@@ -59,6 +225,7 @@ export const AgentChatInput = memo<AgentChatInputProps>(
onInterrupt,
isInterruptPending = false,
leftActions,
contextUsage,
sticky = false,
}) => {
const [input, setInput] = useState(initialValue);
@@ -135,6 +302,9 @@ export const AgentChatInput = memo<AgentChatInputProps>(
<span className="sr-only">Interrupt</span>
</Button>
)}
{contextUsage !== undefined && (
<ContextUsageIndicator usage={contextUsage} />
)}
<Button
size="icon"
variant="default"
@@ -2,6 +2,7 @@ import type { ChatModelsResponse } from "api/api";
import type * as TypesGen from "api/typesGenerated";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { MockWorkspace, MockWorkspaceAgent } from "testHelpers/entities";
import { renderComponent } from "testHelpers/renderHelpers";
@@ -44,6 +45,19 @@ vi.mock("react-router", async () => {
useNavigate: () => mockNavigate,
useParams: () => mockUseParams(),
useOutletContext: () => mockUseOutletContext(),
Link: ({
to,
children,
...props
}: {
to?: string;
children?: ReactNode;
[key: string]: unknown;
}) => (
<a href={typeof to === "string" ? to : "#"} {...props}>
{children}
</a>
),
};
});
@@ -91,6 +105,53 @@ type AgentDetailTestState = {
readonly modelCatalog?: ChatModelsResponse;
};
type MockSocket = {
addEventListener: ReturnType<typeof vi.fn>;
removeEventListener: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
emitMessage: (payload: unknown) => void;
emitError: (payload?: unknown) => void;
};
const createMockSocket = (): MockSocket => {
const messageListeners = new Set<(payload: unknown) => void>();
const errorListeners = new Set<(payload: unknown) => void>();
return {
addEventListener: vi.fn((type: string, listener: (payload: unknown) => void) => {
if (type === "message") {
messageListeners.add(listener);
return;
}
if (type === "error") {
errorListeners.add(listener);
}
}),
removeEventListener: vi.fn(
(type: string, listener: (payload: unknown) => void) => {
if (type === "message") {
messageListeners.delete(listener);
return;
}
if (type === "error") {
errorListeners.delete(listener);
}
},
),
close: vi.fn(),
emitMessage: (payload: unknown) => {
for (const listener of messageListeners) {
listener(payload);
}
},
emitError: (payload?: unknown) => {
for (const listener of errorListeners) {
listener(payload);
}
},
};
};
const installQueryMocks = ({
chatData,
workspaceData,
@@ -336,4 +397,205 @@ describe(AgentDetail.name, () => {
topBarActionsRoot.remove();
rightPanelRoot.remove();
});
it("applies child status events to subagent cards without changing parent status", async () => {
const socket = createMockSocket();
mockWatchChat.mockReturnValue(socket);
const setQueryData = vi.fn();
const invalidateQueries = vi.fn().mockResolvedValue(undefined);
mockUseQueryClient.mockReturnValue({
setQueryData,
invalidateQueries,
});
const workspace = {
...MockWorkspace,
id: "workspace-1",
};
const chatData: TypesGen.ChatWithMessages = {
chat: {
id: "chat-1",
owner_id: "owner-id",
workspace_id: workspace.id,
workspace_agent_id: MockWorkspaceAgent.id,
title: "Parent agent",
status: "running",
model_config: {
model: "gpt-4o",
provider: "openai",
},
created_at: "2026-02-18T00:00:00.000Z",
updated_at: "2026-02-18T00:00:00.000Z",
},
messages: [
{
id: 1,
chat_id: "chat-1",
created_at: "2026-02-18T00:00:01.000Z",
role: "assistant",
hidden: false,
parts: [
{
type: "tool-call",
tool_call_id: "tool-subagent-1",
tool_name: "subagent",
args: {
title: "Child agent",
},
},
{
type: "tool-result",
tool_call_id: "tool-subagent-1",
tool_name: "subagent",
result: {
chat_id: "child-chat-1",
title: "Child agent",
status: "pending",
},
},
],
},
],
};
mockUseOutletContext.mockReturnValue({
chatErrorReasons: {},
setChatErrorReason: vi.fn(),
clearChatErrorReason: vi.fn(),
topBarTitleRef: { current: null },
topBarActionsRef: { current: null },
rightPanelRef: { current: null },
setRightPanelOpen: vi.fn(),
requestArchiveAgent: vi.fn(),
});
installQueryMocks({
chatData,
workspaceData: workspace,
diffUrl: undefined,
});
renderComponent(<AgentDetail />);
expect(screen.getByText("Thinking...")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Spawning Child agent/ }),
).toBeInTheDocument();
socket.emitMessage({
parseError: null,
parsedMessage: {
type: "data",
data: {
type: "status",
chat_id: "child-chat-1",
status: {
status: "completed",
},
},
},
});
await waitFor(() => {
expect(
screen.getByRole("button", { name: /Spawned Child agent/ }),
).toBeInTheDocument();
});
expect(screen.getByText("Thinking...")).toBeInTheDocument();
expect(setQueryData).not.toHaveBeenCalled();
expect(invalidateQueries).not.toHaveBeenCalled();
});
it("renders streamed subagent title before args JSON is complete", async () => {
const socket = createMockSocket();
mockWatchChat.mockReturnValue(socket);
const workspace = {
...MockWorkspace,
id: "workspace-1",
};
const chatData: TypesGen.ChatWithMessages = {
chat: {
id: "chat-1",
owner_id: "owner-id",
workspace_id: workspace.id,
workspace_agent_id: MockWorkspaceAgent.id,
title: "Streaming title",
status: "running",
model_config: {
model: "gpt-4o",
provider: "openai",
},
created_at: "2026-02-18T00:00:00.000Z",
updated_at: "2026-02-18T00:00:00.000Z",
},
messages: [],
};
mockUseOutletContext.mockReturnValue({
chatErrorReasons: {},
setChatErrorReason: vi.fn(),
clearChatErrorReason: vi.fn(),
topBarTitleRef: { current: null },
topBarActionsRef: { current: null },
rightPanelRef: { current: null },
setRightPanelOpen: vi.fn(),
requestArchiveAgent: vi.fn(),
});
installQueryMocks({
chatData,
workspaceData: workspace,
diffUrl: undefined,
});
renderComponent(<AgentDetail />);
socket.emitMessage({
parseError: null,
parsedMessage: {
type: "data",
data: {
type: "message_part",
message_part: {
part: {
type: "tool-call",
tool_call_id: "tool-subagent-stream-1",
tool_name: "subagent",
args_delta: "{\"title\":\"Streamed Child\"",
},
},
},
},
});
await waitFor(() => {
expect(
screen.getByRole("button", { name: /Spawning Streamed Child/ }),
).toBeInTheDocument();
});
socket.emitMessage({
parseError: null,
parsedMessage: {
type: "data",
data: {
type: "message_part",
message_part: {
part: {
type: "tool-call",
tool_call_id: "tool-subagent-stream-1",
tool_name: "subagent",
args_delta: ",\"prompt\":\"Finish setup\"}",
},
},
},
},
});
await waitFor(() => {
expect(
screen.getByRole("button", { name: /Spawning Streamed Child/ }),
).toBeInTheDocument();
});
});
});
+525 -47
View File
@@ -54,7 +54,7 @@ import { createPortal } from "react-dom";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useNavigate, useOutletContext, useParams } from "react-router";
import type { OneWayMessageEvent } from "utils/OneWayWebSocket";
import { AgentChatInput } from "./AgentChatInput";
import { AgentChatInput, type AgentContextUsage } from "./AgentChatInput";
import type { AgentsOutletContext } from "./AgentsPage";
import { FilesChangedPanel } from "./FilesChangedPanel";
import {
@@ -77,11 +77,88 @@ const asString = (value: unknown): string => {
return typeof value === "string" ? value : "";
};
const asTokenCount = (value: unknown): number | undefined => {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
return undefined;
}
return value;
};
const asNonEmptyString = (value: unknown): string | undefined => {
const next = asString(value).trim();
return next.length > 0 ? next : undefined;
};
type ChatMessageWithUsage = TypesGen.ChatMessage & {
readonly input_tokens?: unknown;
readonly output_tokens?: unknown;
readonly total_tokens?: unknown;
readonly reasoning_tokens?: unknown;
readonly cache_creation_tokens?: unknown;
readonly cache_read_tokens?: unknown;
readonly context_limit?: unknown;
};
const extractContextUsageFromMessage = (
message: TypesGen.ChatMessage,
): AgentContextUsage | null => {
const withUsage = message as ChatMessageWithUsage;
const inputTokens = asTokenCount(withUsage.input_tokens);
const outputTokens = asTokenCount(withUsage.output_tokens);
const totalTokens = asTokenCount(withUsage.total_tokens);
const reasoningTokens = asTokenCount(withUsage.reasoning_tokens);
const cacheCreationTokens = asTokenCount(withUsage.cache_creation_tokens);
const cacheReadTokens = asTokenCount(withUsage.cache_read_tokens);
const contextLimitTokens = asTokenCount(withUsage.context_limit);
const components = [
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens,
reasoningTokens,
].filter((value): value is number => value !== undefined);
const derivedUsedTokens =
components.length > 0
? components.reduce((total, value) => total + value, 0)
: undefined;
const usedTokens = totalTokens ?? derivedUsedTokens;
const hasUsage =
usedTokens !== undefined ||
contextLimitTokens !== undefined ||
inputTokens !== undefined ||
outputTokens !== undefined ||
cacheReadTokens !== undefined ||
cacheCreationTokens !== undefined ||
reasoningTokens !== undefined;
if (!hasUsage) {
return null;
}
return {
usedTokens,
contextLimitTokens,
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens,
reasoningTokens,
};
};
const getLatestContextUsage = (
messages: readonly TypesGen.ChatMessage[],
): AgentContextUsage | null => {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const usage = extractContextUsageFromMessage(messages[index]);
if (usage) {
return usage;
}
}
return null;
};
type ChatWithHierarchyMetadata = TypesGen.Chat & {
readonly parent_chat_id?: string;
};
@@ -149,29 +226,358 @@ const parseToolResultIsError = (
return !isCompletedSubagentResult(toolName, result);
};
const tryParseJSONObject = (value: string): unknown | null => {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const first = trimmed[0];
if (first !== "{" && first !== "[") {
return null;
}
try {
return JSON.parse(trimmed);
} catch {
return null;
}
};
const parsePartialJSONString = (
input: string,
startIndex: number,
): { value: string; nextIndex: number } | "incomplete" | null => {
if (input[startIndex] !== "\"") {
return null;
}
let escaped = false;
for (let i = startIndex + 1; i < input.length; i += 1) {
const char = input[i];
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char !== "\"") {
continue;
}
const token = input.slice(startIndex, i + 1);
try {
return {
value: JSON.parse(token) as string,
nextIndex: i + 1,
};
} catch {
return null;
}
}
return "incomplete";
};
const isJSONValueBoundary = (char: string | undefined): boolean =>
char === undefined || char === "," || char === "}" || char === "]" || /\s/.test(char);
const findBalancedJSONEnd = (
input: string,
startIndex: number,
): number | "incomplete" | null => {
const stack: string[] = [];
let escaped = false;
let inString = false;
for (let index = startIndex; index < input.length; index += 1) {
const char = input[index];
if (inString) {
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char === "\"") {
inString = false;
}
continue;
}
switch (char) {
case "\"":
inString = true;
break;
case "{":
case "[":
stack.push(char);
break;
case "}": {
const top = stack.pop();
if (top !== "{") {
return null;
}
break;
}
case "]": {
const top = stack.pop();
if (top !== "[") {
return null;
}
break;
}
default:
break;
}
if (stack.length === 0) {
return index + 1;
}
}
return "incomplete";
};
type PartialJSONValue =
| { status: "ok"; value: unknown; nextIndex: number }
| { status: "incomplete" }
| { status: "invalid" };
const parsePartialJSONValue = (
input: string,
startIndex: number,
): PartialJSONValue => {
let index = startIndex;
while (index < input.length && /\s/.test(input[index])) {
index += 1;
}
if (index >= input.length) {
return { status: "incomplete" };
}
const char = input[index];
if (char === "\"") {
const parsed = parsePartialJSONString(input, index);
if (parsed === "incomplete") {
return { status: "incomplete" };
}
if (!parsed) {
return { status: "invalid" };
}
return {
status: "ok",
value: parsed.value,
nextIndex: parsed.nextIndex,
};
}
if (char === "{" || char === "[") {
const end = findBalancedJSONEnd(input, index);
if (end === "incomplete") {
return { status: "incomplete" };
}
if (end === null) {
return { status: "invalid" };
}
const parsed = tryParseJSONObject(input.slice(index, end));
if (parsed === null) {
return { status: "invalid" };
}
return {
status: "ok",
value: parsed,
nextIndex: end,
};
}
if (input.startsWith("true", index)) {
const next = index + 4;
if (!isJSONValueBoundary(input[next])) {
return { status: "invalid" };
}
return { status: "ok", value: true, nextIndex: next };
}
if ("true".startsWith(input.slice(index))) {
return { status: "incomplete" };
}
if (input.startsWith("false", index)) {
const next = index + 5;
if (!isJSONValueBoundary(input[next])) {
return { status: "invalid" };
}
return { status: "ok", value: false, nextIndex: next };
}
if ("false".startsWith(input.slice(index))) {
return { status: "incomplete" };
}
if (input.startsWith("null", index)) {
const next = index + 4;
if (!isJSONValueBoundary(input[next])) {
return { status: "invalid" };
}
return { status: "ok", value: null, nextIndex: next };
}
if ("null".startsWith(input.slice(index))) {
return { status: "incomplete" };
}
if (char === "-" || (char >= "0" && char <= "9")) {
let end = index;
while (end < input.length && /[0-9eE+.-]/.test(input[end])) {
end += 1;
}
const token = input.slice(index, end);
if (!token) {
return { status: "invalid" };
}
if (end === input.length && /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?)?$/.test(token)) {
return { status: "incomplete" };
}
if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(token)) {
return { status: "invalid" };
}
if (!isJSONValueBoundary(input[end])) {
return { status: "invalid" };
}
return { status: "ok", value: Number(token), nextIndex: end };
}
return { status: "invalid" };
};
const parsePartialJSONObject = (value: string): Record<string, unknown> | null => {
const trimmed = value.trim();
if (!trimmed.startsWith("{")) {
return null;
}
let index = 1;
const parsed: Record<string, unknown> = {};
let hasFields = false;
while (index < trimmed.length) {
while (index < trimmed.length && /\s/.test(trimmed[index])) {
index += 1;
}
if (index >= trimmed.length) {
break;
}
if (trimmed[index] === "}") {
return hasFields ? parsed : null;
}
if (trimmed[index] === ",") {
index += 1;
continue;
}
const key = parsePartialJSONString(trimmed, index);
if (key === "incomplete") {
break;
}
if (!key) {
return hasFields ? parsed : null;
}
index = key.nextIndex;
while (index < trimmed.length && /\s/.test(trimmed[index])) {
index += 1;
}
if (index >= trimmed.length || trimmed[index] !== ":") {
break;
}
index += 1;
const nextValue = parsePartialJSONValue(trimmed, index);
if (nextValue.status === "incomplete") {
break;
}
if (nextValue.status === "invalid") {
return hasFields ? parsed : null;
}
parsed[key.value] = nextValue.value;
hasFields = true;
index = nextValue.nextIndex;
while (index < trimmed.length && /\s/.test(trimmed[index])) {
index += 1;
}
if (index >= trimmed.length) {
break;
}
if (trimmed[index] === ",") {
index += 1;
continue;
}
if (trimmed[index] === "}") {
return parsed;
}
return hasFields ? parsed : null;
}
return hasFields ? parsed : null;
};
const parseStreamingJSON = (value: string): unknown | null => {
const complete = tryParseJSONObject(value);
if (complete !== null) {
return complete;
}
return parsePartialJSONObject(value);
};
type StreamPayloadMerge = {
value: unknown;
rawText?: string;
};
const mergeStreamPayload = (
existing: unknown,
existingValue: unknown,
existingRawText: string | undefined,
value: unknown,
delta: unknown,
): unknown => {
): StreamPayloadMerge => {
if (value !== undefined) {
return value;
if (typeof value !== "string") {
return { value };
}
const parsed = parseStreamingJSON(value);
if (parsed !== null) {
return { value: parsed, rawText: value };
}
return { value, rawText: value };
}
const chunk = typeof delta === "string" ? delta : "";
if (!chunk) {
return existing;
return {
value: existingValue,
rawText: existingRawText,
};
}
if (typeof existing === "string") {
return `${existing}${chunk}`;
if (
existingValue !== undefined &&
typeof existingValue !== "string" &&
existingRawText === undefined
) {
return {
value: existingValue,
};
}
if (existing === undefined) {
return chunk;
}
const base = existingRawText ?? (typeof existingValue === "string" ? existingValue : "");
const rawText = `${base}${chunk}`;
const parsed = parseStreamingJSON(rawText);
return existing;
return {
value: parsed ?? rawText,
rawText,
};
};
type ParsedToolCall = {
@@ -412,12 +818,14 @@ type StreamToolCall = {
id: string;
name: string;
args?: unknown;
argsRaw?: string;
};
type StreamToolResult = {
id: string;
name: string;
result?: unknown;
resultRaw?: string;
isError: boolean;
};
@@ -550,7 +958,9 @@ const DiffStatsBadge: FC<DiffStatsBadgeProps> = ({
const ChatMessageItem = memo<{
message: TypesGen.ChatMessage;
parsed: ParsedMessageContent;
}>(({ message, parsed }) => {
subagentTitles?: Map<string, string>;
subagentStatusOverrides?: Map<string, TypesGen.ChatStatus>;
}>(({ message, parsed, subagentTitles, subagentStatusOverrides }) => {
const isUser = message.role === "user";
// Skip messages that only carry tool results. Those results
@@ -594,6 +1004,8 @@ const ChatMessageItem = memo<{
result={tool.result}
status={tool.status}
isError={tool.isError}
subagentTitles={subagentTitles}
subagentStatusOverrides={subagentStatusOverrides}
/>
))}
{!hasRenderableContent && (
@@ -618,7 +1030,9 @@ ChatMessageItem.displayName = "ChatMessageItem";
const StreamingOutput = memo<{
streamState: StreamState | null;
streamTools: MergedTool[];
}>(({ streamState, streamTools }) => {
subagentTitles?: Map<string, string>;
subagentStatusOverrides?: Map<string, TypesGen.ChatStatus>;
}>(({ streamState, streamTools, subagentTitles, subagentStatusOverrides }) => {
const conversationItemProps = { role: "assistant" as const };
return (
@@ -644,6 +1058,8 @@ const StreamingOutput = memo<{
result={tool.result}
status={tool.status}
isError={tool.isError}
subagentTitles={subagentTitles}
subagentStatusOverrides={subagentStatusOverrides}
/>
))}
</div>
@@ -671,6 +1087,9 @@ export const AgentDetail: FC = () => {
const [chatStatus, setChatStatus] = useState<TypesGen.ChatStatus | null>(
null,
);
const [subagentStatusOverrides, setSubagentStatusOverrides] = useState<
Map<string, TypesGen.ChatStatus>
>(new Map());
const [selectedModel, setSelectedModel] = useState("");
const chatErrorReasons = outletContext?.chatErrorReasons ?? {};
const setChatErrorReason =
@@ -822,6 +1241,7 @@ export const AgentDetail: FC = () => {
cancelScheduledStreamReset();
setStreamState(null);
setStreamError(null);
setSubagentStatusOverrides(new Map());
const socket = watchChat(agentId);
const handleMessage = (
@@ -915,6 +1335,7 @@ export const AgentDetail: FC = () => {
const existing = nextState.toolCalls[toolCallID];
const nextArgs = mergeStreamPayload(
existing?.args,
existing?.argsRaw,
part.args,
part.args_delta,
);
@@ -926,7 +1347,8 @@ export const AgentDetail: FC = () => {
[toolCallID]: {
id: toolCallID,
name: toolName || existing?.name || "Tool",
args: nextArgs,
args: nextArgs.value,
argsRaw: nextArgs.rawText,
},
},
};
@@ -956,13 +1378,18 @@ export const AgentDetail: FC = () => {
const existing = nextState.toolResults[toolCallID];
const nextResult = mergeStreamPayload(
existing?.result,
existing?.resultRaw,
part.result,
part.result_delta,
);
const nextToolName = toolName || existing?.name || "Tool";
const nextIsError =
existing?.isError ||
parseToolResultIsError(nextToolName, part, nextResult);
parseToolResultIsError(
nextToolName,
part,
nextResult.value,
);
return {
...nextState,
@@ -971,7 +1398,8 @@ export const AgentDetail: FC = () => {
[toolCallID]: {
id: toolCallID,
name: nextToolName,
result: nextResult,
result: nextResult.value,
resultRaw: nextResult.rawText,
isError: nextIsError,
},
},
@@ -987,37 +1415,52 @@ export const AgentDetail: FC = () => {
case "status": {
const status = asRecord(streamEvent.status);
const nextStatus = asString(status?.status) as TypesGen.ChatStatus;
if (nextStatus) {
setChatStatus(nextStatus);
if (agentId && nextStatus !== "error") {
clearChatErrorReason(agentId);
}
updateSidebarChat((chat) => ({
...chat,
status: nextStatus,
updated_at: new Date().toISOString(),
}));
// Always refresh diff queries on any status event
// because the background refresh may have discovered
// a new PR or updated diff contents.
if (agentId) {
void Promise.all([
queryClient.invalidateQueries({
queryKey: chatDiffStatusKey(agentId),
}),
queryClient.invalidateQueries({
queryKey: chatDiffContentsKey(agentId),
}),
]);
}
const shouldRefreshQueries =
nextStatus === "completed" ||
nextStatus === "error" ||
nextStatus === "paused" ||
nextStatus === "waiting";
if (shouldRefreshQueries) {
void queryClient.invalidateQueries({ queryKey: chatsKey });
}
if (!nextStatus) {
return;
}
const eventChatID = asString(streamEvent.chat_id);
if (eventChatID && eventChatID !== agentId) {
setSubagentStatusOverrides((prev) => {
if (prev.get(eventChatID) === nextStatus) {
return prev;
}
const next = new Map(prev);
next.set(eventChatID, nextStatus);
return next;
});
return;
}
setChatStatus(nextStatus);
if (agentId && nextStatus !== "error") {
clearChatErrorReason(agentId);
}
updateSidebarChat((chat) => ({
...chat,
status: nextStatus,
updated_at: new Date().toISOString(),
}));
// Always refresh diff queries on any status event
// because the background refresh may have discovered
// a new PR or updated diff contents.
if (agentId) {
void Promise.all([
queryClient.invalidateQueries({
queryKey: chatDiffStatusKey(agentId),
}),
queryClient.invalidateQueries({
queryKey: chatDiffContentsKey(agentId),
}),
]);
}
const shouldRefreshQueries =
nextStatus === "completed" ||
nextStatus === "error" ||
nextStatus === "paused" ||
nextStatus === "waiting";
if (shouldRefreshQueries) {
void queryClient.invalidateQueries({ queryKey: chatsKey });
}
return;
}
@@ -1075,6 +1518,10 @@ export const AgentDetail: FC = () => {
);
return list;
}, [messagesById]);
const latestContextUsage = useMemo(
() => getLatestContextUsage(messages),
[messages],
);
const isStreaming =
Boolean(streamState) ||
@@ -1206,6 +1653,26 @@ export const AgentDetail: FC = () => {
[visibleMessages, globalToolResults],
);
// Build a lookup of sub-agent chat_id → title from spawn tool
// results so await/message tools can show the title while still
// in progress (before their own result arrives with the title).
const subagentTitles = useMemo(() => {
const map = new Map<string, string>();
for (const { parsed } of parsedMessages) {
for (const tool of parsed.tools) {
if (tool.name !== "subagent") continue;
const rec = asRecord(tool.result);
if (!rec) continue;
const chatId = asString(rec.chat_id);
const title = asString(rec.title);
if (chatId && title) {
map.set(chatId, title);
}
}
}
return map;
}, [parsedMessages]);
// Group messages into sections so each user message can be CSS
// sticky within its section. When the section scrolls out, the
// sticky user message scrolls away and the next one takes over
@@ -1456,6 +1923,10 @@ export const AgentDetail: FC = () => {
<ChatMessageItem
message={message}
parsed={parsed}
subagentTitles={subagentTitles}
subagentStatusOverrides={
subagentStatusOverrides
}
/>
</div>
) : (
@@ -1463,6 +1934,10 @@ export const AgentDetail: FC = () => {
key={message.id}
message={message}
parsed={parsed}
subagentTitles={subagentTitles}
subagentStatusOverrides={
subagentStatusOverrides
}
/>
);
})}
@@ -1475,6 +1950,8 @@ export const AgentDetail: FC = () => {
<StreamingOutput
streamState={streamState}
streamTools={streamTools}
subagentTitles={subagentTitles}
subagentStatusOverrides={subagentStatusOverrides}
/>
</div>
)}
@@ -1495,6 +1972,7 @@ export const AgentDetail: FC = () => {
isStreaming={isStreaming}
onInterrupt={stableOnInterrupt}
isInterruptPending={interruptMutation.isPending}
contextUsage={latestContextUsage}
hasModelOptions={hasModelOptions}
selectedModel={selectedModel}
onModelChange={setSelectedModel}