Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f8ec02b
feat(platform/copilot): message timestamps + accurate thought-for time
majdyz Apr 23, 2026
5b5f20e
fix(backend/copilot): reset per-turn reasoning counters + tests
majdyz Apr 23, 2026
f4692e6
refactor(platform/copilot): address CodeRabbit nits on timestamps PR
majdyz Apr 23, 2026
f9eac1d
fix(frontend/copilot): accept Date object for created_at
majdyz Apr 23, 2026
b93db17
test(frontend/copilot): cover TurnStatsBar + combined duration maps
majdyz Apr 23, 2026
41d0fe7
test(platform/copilot): address CodeRabbit review comments on test files
majdyz Apr 23, 2026
a05b506
refactor(platform/copilot): collapse 3 parallel stat maps into one Tu…
majdyz Apr 23, 2026
ae5b382
feat(frontend/copilot): swap turn-stats label text on hover/click ins…
majdyz Apr 23, 2026
a2514e2
refactor(platform/copilot): drop reasoningDurationMs — whole-turn dur…
majdyz Apr 23, 2026
98df6c8
fix(frontend/copilot): anchor live Thinking-Xs counter to last server…
majdyz Apr 23, 2026
9caa8c8
fix(frontend/copilot): show date + time on hover, not just date
majdyz Apr 23, 2026
6e6ddb0
fix(frontend/copilot): anchor live Thinking timer to current turn's u…
majdyz Apr 23, 2026
9323f98
fix(frontend/copilot): simpler anchor + subtle hover fade
majdyz Apr 23, 2026
99a3946
fix(frontend/copilot): hover-only label swap — drop click-to-pin
majdyz Apr 23, 2026
4cec94d
feat(frontend/copilot): show timestamp on user messages on hover (alo…
majdyz Apr 23, 2026
9549b65
fix(frontend/copilot): sub-second turns + late anchorIso sync
majdyz Apr 23, 2026
ca7a208
test(frontend/copilot): cover useElapsedTimer anchor paths for codecov
majdyz Apr 23, 2026
7609301
test(platform/copilot): cover more changed lines for codecov patch
majdyz Apr 23, 2026
a37699c
fix(frontend/copilot): widen initialProps type for useElapsedTimer te…
majdyz Apr 23, 2026
80e6882
Merge branch 'dev' of github.com:Significant-Gravitas/AutoGPT into fe…
majdyz Apr 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions autogpt_platform/backend/backend/copilot/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -618,8 +618,13 @@ async def update_message_content_by_sequence(
return False


async def set_turn_duration(session_id: str, duration_ms: int) -> None:
"""Set durationMs on the last assistant message in a session.
async def set_turn_duration(
session_id: str,
duration_ms: int,
reasoning_duration_ms: int | None = None,
) -> None:
"""Set durationMs (and optionally reasoningDurationMs) on the last
assistant message in a session.

Updates the Redis cache in-place instead of invalidating it.
Invalidation would delete the key, creating a window where concurrent
Expand All @@ -632,9 +637,12 @@ async def set_turn_duration(session_id: str, duration_ms: int) -> None:
order={"sequence": "desc"},
)
if last_msg:
data: dict[str, int] = {"durationMs": duration_ms}
if reasoning_duration_ms is not None:
data["reasoningDurationMs"] = reasoning_duration_ms
await PrismaChatMessage.prisma().update(
where={"id": last_msg.id},
data={"durationMs": duration_ms},
data=data, # type: ignore[arg-type]
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
# Update cache in-place rather than invalidating to avoid a
# race window where the empty cache gets re-populated with
Expand All @@ -644,5 +652,7 @@ async def set_turn_duration(session_id: str, duration_ms: int) -> None:
for msg in reversed(session.messages):
if msg.role == "assistant":
msg.duration_ms = duration_ms
if reasoning_duration_ms is not None:
msg.reasoning_duration_ms = reasoning_duration_ms
break
await cache_chat_session(session)
Comment thread
majdyz marked this conversation as resolved.
4 changes: 4 additions & 0 deletions autogpt_platform/backend/backend/copilot/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ class ChatMessage(BaseModel):
function_call: dict | None = None
sequence: int | None = None
duration_ms: int | None = None
reasoning_duration_ms: int | None = None
created_at: datetime | None = None

@staticmethod
def from_db(prisma_message: PrismaChatMessage) -> "ChatMessage":
Expand All @@ -86,6 +88,8 @@ def from_db(prisma_message: PrismaChatMessage) -> "ChatMessage":
function_call=_parse_json_field(prisma_message.functionCall),
sequence=prisma_message.sequence,
duration_ms=prisma_message.durationMs,
reasoning_duration_ms=prisma_message.reasoningDurationMs,
created_at=prisma_message.createdAt,
)


Expand Down
63 changes: 62 additions & 1 deletion autogpt_platform/backend/backend/copilot/stream_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,42 @@ async def create_session(
_META_TTL_REFRESH_INTERVAL = 60 # seconds


async def _record_reasoning_event(
session_id: str,
chunk: "StreamReasoningStart | StreamReasoningEnd",
) -> None:
"""Accumulate actual model reasoning time in the session meta hash.

Called from ``publish_chunk`` for reasoning-start/end events so the final
``set_turn_duration`` can distinguish pure thinking time from overall
wall-clock time (which includes tool execution).
"""
redis = await get_redis_async()
meta_key = _get_session_meta_key(session_id)
now_iso = datetime.now(timezone.utc).isoformat()
if isinstance(chunk, StreamReasoningStart):
await redis.hset(meta_key, "reasoning_started_at", now_iso) # type: ignore[misc]
return

started_at_raw = await redis.hget(meta_key, "reasoning_started_at") # type: ignore[misc]
if started_at_raw is None:
return
started_at_str = (
started_at_raw.decode() if isinstance(started_at_raw, bytes) else started_at_raw
)
try:
started_at = datetime.fromisoformat(started_at_str)
except (ValueError, TypeError):
return
if started_at.tzinfo is None:
started_at = started_at.replace(tzinfo=timezone.utc)
elapsed_ms = max(
0, int((datetime.now(timezone.utc) - started_at).total_seconds() * 1000)
)
await redis.hincrby(meta_key, "reasoning_ms_total", elapsed_ms) # type: ignore[misc]
Comment thread
majdyz marked this conversation as resolved.
Outdated
await redis.hdel(meta_key, "reasoning_started_at") # type: ignore[misc]
Comment thread
majdyz marked this conversation as resolved.
Outdated


async def publish_chunk(
turn_id: str,
chunk: StreamBaseResponse,
Expand All @@ -249,6 +285,14 @@ async def publish_chunk(
chunk_json = chunk.model_dump_json()
message_id = "0-0"

if session_id and isinstance(chunk, (StreamReasoningStart, StreamReasoningEnd)):
try:
await _record_reasoning_event(session_id, chunk)
except Exception as e:
logger.warning(
"Failed to record reasoning timing for %s: %s", session_id, e
)

# Build log metadata
log_meta = {
"component": "StreamRegistry",
Expand Down Expand Up @@ -890,10 +934,27 @@ async def mark_session_completed(
created_at_raw,
)

reasoning_duration_ms: int | None = None
if meta and not error_message:
reasoning_raw = meta.get("reasoning_ms_total")
if reasoning_raw:
try:
reasoning_duration_ms = max(0, int(reasoning_raw))
except (ValueError, TypeError):
logger.warning(
"Failed to parse reasoning_ms_total for %s (value=%r)",
session_id,
reasoning_raw,
)
Comment thread
majdyz marked this conversation as resolved.
Outdated

# Persist duration on the last assistant message
if duration_ms is not None:
try:
await chat_db().set_turn_duration(session_id, duration_ms)
await chat_db().set_turn_duration(
session_id,
duration_ms,
reasoning_duration_ms=reasoning_duration_ms,
)
except Exception as e:
logger.warning(f"Failed to save turn duration for {session_id}: {e}")

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- Add reasoningDurationMs column to ChatMessage so the copilot UI can show
-- actual model-reasoning time instead of whole-turn wall clock (which
-- includes tool execution).
ALTER TABLE "ChatMessage" ADD COLUMN "reasoningDurationMs" INTEGER;
5 changes: 3 additions & 2 deletions autogpt_platform/backend/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -294,8 +294,9 @@ model ChatMessage {
functionCall Json? // Deprecated but kept for compatibility

// Ordering within session
sequence Int
durationMs Int? // Wall-clock milliseconds for this assistant turn
sequence Int
durationMs Int? // Wall-clock milliseconds for this assistant turn
reasoningDurationMs Int? // Milliseconds the model spent inside reasoning blocks

@@unique([sessionId, sequence])
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ export function CopilotPage() {
handleCancelDelete,
// Historical durations for persisted timer stats
historicalDurations,
historicalReasoningDurations,
messageTimestamps,
// Rate limit reset
rateLimitMessage,
dismissRateLimit,
Expand Down Expand Up @@ -224,6 +226,8 @@ export function CopilotPage() {
droppedFiles={droppedFiles}
onDroppedFilesConsumed={handleDroppedFilesConsumed}
historicalDurations={historicalDurations}
historicalReasoningDurations={historicalReasoningDurations}
messageTimestamps={messageTimestamps}
/>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ vi.mock("nuqs", () => ({
vi.mock("../helpers/convertChatSessionToUiMessages", () => ({
convertChatSessionMessagesToUiMessages: vi.fn(() => ({
messages: [],
historicalDurations: new Map(),
durations: new Map(),
reasoningDurations: new Map(),
timestamps: new Map(),
})),
}));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ vi.mock("@/app/api/__generated__/endpoints/chat/chat", () => ({
}));

vi.mock("../helpers/convertChatSessionToUiMessages", () => ({
convertChatSessionMessagesToUiMessages: vi.fn(() => ({ messages: [] })),
convertChatSessionMessagesToUiMessages: vi.fn(() => ({
messages: [],
durations: new Map(),
reasoningDurations: new Map(),
timestamps: new Map(),
})),
extractToolOutputsFromRaw: vi.fn(() => []),
}));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ export interface ChatContainerProps {
onDroppedFilesConsumed?: () => void;
/** Duration in ms for historical turns, keyed by message ID. */
historicalDurations?: Map<string, number>;
/** Pure reasoning-time in ms for historical turns, keyed by message ID. */
historicalReasoningDurations?: Map<string, number>;
/** Server-issued message timestamps (ISO 8601), keyed by message ID. */
messageTimestamps?: Map<string, string>;
}
export const ChatContainer = ({
messages,
Expand All @@ -63,6 +67,8 @@ export const ChatContainer = ({
droppedFiles,
onDroppedFilesConsumed,
historicalDurations,
historicalReasoningDurations,
messageTimestamps,
}: ChatContainerProps) => {
const isArtifactsEnabled = useGetFlag(Flag.ARTIFACTS);
const isArtifactPanelOpen = useCopilotUIStore((s) => s.artifactPanel.isOpen);
Expand Down Expand Up @@ -117,6 +123,8 @@ export const ChatContainer = ({
onLoadMore={onLoadMore}
onRetry={handleRetry}
historicalDurations={historicalDurations}
historicalReasoningDurations={historicalReasoningDurations}
messageTimestamps={messageTimestamps}
queuedMessages={queuedMessages}
/>
<motion.div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ interface Props {
onLoadMore?: () => void;
onRetry?: () => void;
historicalDurations?: Map<string, number>;
historicalReasoningDurations?: Map<string, number>;
messageTimestamps?: Map<string, string>;
/** Pending queued messages waiting to be injected, shown at the end of chat. */
queuedMessages?: string[];
}
Expand Down Expand Up @@ -257,6 +259,8 @@ export function ChatMessagesContainer({
onLoadMore,
onRetry,
historicalDurations,
historicalReasoningDurations,
messageTimestamps,
queuedMessages,
}: Props) {
// Hide the container for one frame when messages first load so
Expand Down Expand Up @@ -442,6 +446,10 @@ export function ChatMessagesContainer({
: undefined
}
durationMs={historicalDurations?.get(message.id)}
reasoningDurationMs={historicalReasoningDurations?.get(
message.id,
)}
timestamp={messageTimestamps?.get(message.id)}
/>
)}
{isLastAssistant && showThinking && (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,52 +1,100 @@
import type { UIDataTypes, UIMessage, UITools } from "ai";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { formatElapsed } from "./formatElapsed";
import { getWorkDoneCounters } from "./useWorkDoneCounters";

interface Props {
turnMessages: UIMessage<unknown, UIDataTypes, UITools>[];
elapsedSeconds?: number;
durationMs?: number;
reasoningDurationMs?: number;
timestamp?: string;
}

function formatLocalTimestamp(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
return date.toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "medium",
});
}

export function TurnStatsBar({
turnMessages,
elapsedSeconds,
durationMs,
reasoningDurationMs,
timestamp,
}: Props) {
const { counters } = getWorkDoneCounters(turnMessages);

// Prefer live elapsedSeconds, fall back to persisted durationMs
const displaySeconds =
elapsedSeconds !== undefined && elapsedSeconds > 0
? elapsedSeconds
: durationMs !== undefined
? Math.round(durationMs / 1000)
: undefined;
// Prefer live elapsedSeconds while streaming. Once the turn is finalized
// use reasoningDurationMs when the backend recorded actual reasoning time
// — it excludes tool execution, which the user perceives as dead time.
// Fall back to the whole-turn wall clock for older turns that never had
// a reasoningDurationMs recorded.
let displaySeconds: number | undefined;
if (elapsedSeconds !== undefined && elapsedSeconds > 0) {
displaySeconds = elapsedSeconds;
} else if (reasoningDurationMs !== undefined && reasoningDurationMs > 0) {
displaySeconds = Math.max(1, Math.round(reasoningDurationMs / 1000));
} else if (durationMs !== undefined && durationMs > 0) {
displaySeconds = Math.round(durationMs / 1000);
}

const hasTime = displaySeconds !== undefined && displaySeconds > 0;
const localTime = timestamp ? formatLocalTimestamp(timestamp) : null;

if (counters.length === 0 && !hasTime && !localTime) return null;

if (counters.length === 0 && !hasTime) return null;
const timeLabel = hasTime ? (
<span className="cursor-default text-[11px] tabular-nums text-neutral-500">
Thought for {formatElapsed(displaySeconds!)}
</span>
) : null;

return (
<div className="mt-2 flex items-center gap-1.5">
{hasTime && (
<span className="text-[11px] tabular-nums text-neutral-500">
Thought for {formatElapsed(displaySeconds)}
</span>
)}
{counters.map(function renderCounter(counter, index) {
const needsDot = index > 0 || hasTime;
return (
<span key={counter.category} className="flex items-center gap-1">
{needsDot && (
<span className="text-xs text-neutral-300">&middot;</span>
)}
<span className="text-[11px] tabular-nums text-neutral-500">
{counter.count} {counter.label}
<TooltipProvider>
<div className="mt-2 flex items-center gap-1.5">
{timeLabel &&
(localTime ? (
<Tooltip>
<TooltipTrigger asChild>{timeLabel}</TooltipTrigger>
<TooltipContent side="top">{localTime}</TooltipContent>
</Tooltip>
) : (
timeLabel
))}
{!hasTime && localTime && (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default text-[11px] tabular-nums text-neutral-500">
{localTime}
</span>
</TooltipTrigger>
<TooltipContent side="top">{localTime}</TooltipContent>
</Tooltip>
)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
{counters.map(function renderCounter(counter, index) {
const needsDot = index > 0 || hasTime || !!localTime;
return (
<span key={counter.category} className="flex items-center gap-1">
{needsDot && (
<span className="text-xs text-neutral-300">&middot;</span>
)}
<span className="text-[11px] tabular-nums text-neutral-500">
{counter.count} {counter.label}
</span>
</span>
</span>
);
})}
</div>
);
})}
</div>
</TooltipProvider>
);
}
Loading
Loading