mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
subagents wip
This commit is contained in:
Generated
+21
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Generated
+23900
-22020
File diff suppressed because it is too large
Load Diff
+288
-30
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Generated
+7
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Generated
+62
-41
@@ -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
|
||||
|
||||
|
||||
Generated
+34
-13
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Generated
+3370
-4534
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user