mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
feat(frontend/copilot): add per-turn work-done summary stats (#12257)
## Summary - Adds per-turn work-done counters (e.g. "3 searches", "1 agent run") shown as plain text on the final assistant message of each user/assistant interaction pair - Counters aggregate tool calls by category (searches, agents run, blocks run, agents created/edited, agents scheduled) - Copy and TTS actions now appear only on the final assistant message per turn, with text aggregated from all assistant messages in that turn - Removes the global JobStatsBar above the chat input Resolves: SECRT-2026 ## Test plan - [ ] Work-done counters appear only on the last assistant message of each turn (not on intermediate assistant messages) - [ ] Counters increment correctly as tool call parts appear in messages - [ ] Internal operations (add_understanding, search_docs, get_doc_page, find_block) are NOT counted - [ ] Max 3 counter categories shown, sorted by volume - [ ] Copy/TTS actions appear only on the final assistant message per turn - [ ] Copy/TTS aggregate text from all assistant messages in the turn - [ ] No counters or actions shown while streaming is still in progress - [ ] No type errors, lint errors, or format issues introduced Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -733,7 +733,10 @@ async def mark_session_completed(
|
||||
# This is the SINGLE place that publishes StreamFinish — services and
|
||||
# the processor must NOT publish it themselves.
|
||||
try:
|
||||
await publish_chunk(turn_id, StreamFinish())
|
||||
await publish_chunk(
|
||||
turn_id,
|
||||
StreamFinish(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to publish StreamFinish for session {session_id}: {e}. "
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
import { Message, MessageContent } from "@/components/ai-elements/message";
|
||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||
import { FileUIPart, UIDataTypes, UIMessage, UITools } from "ai";
|
||||
import { TOOL_PART_PREFIX } from "../JobStatsBar/constants";
|
||||
import { TurnStatsBar } from "../JobStatsBar/TurnStatsBar";
|
||||
import { parseSpecialMarkers } from "./helpers";
|
||||
import { AssistantMessageActions } from "./components/AssistantMessageActions";
|
||||
import { MessageAttachments } from "./components/MessageAttachments";
|
||||
@@ -21,6 +23,23 @@ interface Props {
|
||||
sessionID?: string | null;
|
||||
}
|
||||
|
||||
/** Collect all messages belonging to a turn: the user message + every
|
||||
* assistant message up to (but not including) the next user message. */
|
||||
function getTurnMessages(
|
||||
messages: UIMessage<unknown, UIDataTypes, UITools>[],
|
||||
lastAssistantIndex: number,
|
||||
): UIMessage<unknown, UIDataTypes, UITools>[] {
|
||||
const userIndex = messages.findLastIndex(
|
||||
(m, i) => i < lastAssistantIndex && m.role === "user",
|
||||
);
|
||||
const nextUserIndex = messages.findIndex(
|
||||
(m, i) => i > lastAssistantIndex && m.role === "user",
|
||||
);
|
||||
const start = userIndex >= 0 ? userIndex : lastAssistantIndex;
|
||||
const end = nextUserIndex >= 0 ? nextUserIndex : messages.length;
|
||||
return messages.slice(start, end);
|
||||
}
|
||||
|
||||
export function ChatMessagesContainer({
|
||||
messages,
|
||||
status,
|
||||
@@ -31,9 +50,6 @@ export function ChatMessagesContainer({
|
||||
}: Props) {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
|
||||
// Determine if something is visibly "in-flight" in the last assistant message:
|
||||
// - Text is actively streaming (last part is non-empty text)
|
||||
// - A tool call is pending (state is input-streaming or input-available)
|
||||
const hasInflight = (() => {
|
||||
if (lastMessage?.role !== "assistant") return false;
|
||||
const parts = lastMessage.parts;
|
||||
@@ -41,13 +57,11 @@ export function ChatMessagesContainer({
|
||||
|
||||
const lastPart = parts[parts.length - 1];
|
||||
|
||||
// Text is actively being written
|
||||
if (lastPart.type === "text" && lastPart.text.trim().length > 0)
|
||||
return true;
|
||||
|
||||
// A tool call is still pending (no output yet)
|
||||
if (
|
||||
lastPart.type.startsWith("tool-") &&
|
||||
lastPart.type.startsWith(TOOL_PART_PREFIX) &&
|
||||
"state" in lastPart &&
|
||||
(lastPart.state === "input-streaming" ||
|
||||
lastPart.state === "input-available")
|
||||
@@ -80,9 +94,13 @@ export function ChatMessagesContainer({
|
||||
const isCurrentlyStreaming =
|
||||
isLastAssistant &&
|
||||
(status === "streaming" || status === "submitted");
|
||||
|
||||
const isAssistant = message.role === "assistant";
|
||||
|
||||
const nextMessage = messages[messageIndex + 1];
|
||||
const isLastInTurn =
|
||||
message.role === "assistant" &&
|
||||
isAssistant &&
|
||||
messageIndex <= messages.length - 1 &&
|
||||
(!nextMessage || nextMessage.role === "user");
|
||||
const textParts = message.parts.filter(
|
||||
(p): p is Extract<typeof p, { type: "text" }> => p.type === "text",
|
||||
@@ -118,6 +136,11 @@ export function ChatMessagesContainer({
|
||||
partIndex={i}
|
||||
/>
|
||||
))}
|
||||
{isLastInTurn && !isCurrentlyStreaming && (
|
||||
<TurnStatsBar
|
||||
turnMessages={getTurnMessages(messages, messageIndex)}
|
||||
/>
|
||||
)}
|
||||
{isLastAssistant && showThinking && (
|
||||
<ThinkingIndicator active={showThinking} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { UIDataTypes, UIMessage, UITools } from "ai";
|
||||
import { getWorkDoneCounters } from "./useWorkDoneCounters";
|
||||
|
||||
interface Props {
|
||||
turnMessages: UIMessage<unknown, UIDataTypes, UITools>[];
|
||||
}
|
||||
|
||||
export function TurnStatsBar({ turnMessages }: Props) {
|
||||
const { counters } = getWorkDoneCounters(turnMessages);
|
||||
|
||||
if (counters.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
{counters.map(function renderCounter(counter, index) {
|
||||
return (
|
||||
<span key={counter.category} className="flex items-center gap-1">
|
||||
{index > 0 && (
|
||||
<span className="text-xs text-neutral-300">·</span>
|
||||
)}
|
||||
<span className="text-[11px] tabular-nums text-neutral-500">
|
||||
{counter.count} {counter.label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const TOOL_PART_PREFIX = "tool-";
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { UIDataTypes, UIMessage, UITools } from "ai";
|
||||
import { TOOL_PART_PREFIX } from "./constants";
|
||||
|
||||
const TOOL_TO_CATEGORY: Record<string, string> = {
|
||||
find_agent: "search",
|
||||
find_library_agent: "search",
|
||||
run_agent: "agent run",
|
||||
run_block: "block run",
|
||||
create_agent: "agent created",
|
||||
edit_agent: "agent edited",
|
||||
schedule_agent: "agent scheduled",
|
||||
};
|
||||
|
||||
const MAX_COUNTERS = 3;
|
||||
|
||||
function pluralize(label: string, count: number): string {
|
||||
if (count === 1) return label;
|
||||
|
||||
// "agent created" -> "agents created", "agent edited" -> "agents edited"
|
||||
const nounVerbMatch = label.match(
|
||||
/^(\w+)\s+(created|edited|scheduled|run)$/i,
|
||||
);
|
||||
if (nounVerbMatch) {
|
||||
return pluralizeWord(nounVerbMatch[1]) + " " + nounVerbMatch[2];
|
||||
}
|
||||
|
||||
return pluralizeWord(label);
|
||||
}
|
||||
|
||||
function pluralizeWord(word: string): string {
|
||||
if (word.endsWith("ch") || word.endsWith("sh") || word.endsWith("x"))
|
||||
return word + "es";
|
||||
return word + "s";
|
||||
}
|
||||
|
||||
export interface WorkDoneCounter {
|
||||
label: string;
|
||||
count: number;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export function getWorkDoneCounters(
|
||||
messages: UIMessage<unknown, UIDataTypes, UITools>[],
|
||||
) {
|
||||
const categoryCounts = new Map<string, number>();
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role !== "assistant") continue;
|
||||
|
||||
for (const part of message.parts) {
|
||||
if (!part.type.startsWith(TOOL_PART_PREFIX)) continue;
|
||||
|
||||
const toolName = part.type.replace(TOOL_PART_PREFIX, "");
|
||||
const category = TOOL_TO_CATEGORY[toolName];
|
||||
if (!category) continue;
|
||||
|
||||
categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const counters: WorkDoneCounter[] = Array.from(categoryCounts.entries())
|
||||
.map(function toCounter([category, count]) {
|
||||
return {
|
||||
label: pluralize(category, count),
|
||||
count,
|
||||
category,
|
||||
};
|
||||
})
|
||||
.sort(function byCountDesc(a, b) {
|
||||
return b.count - a.count;
|
||||
})
|
||||
.slice(0, MAX_COUNTERS);
|
||||
|
||||
return { counters };
|
||||
}
|
||||
Reference in New Issue
Block a user