Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
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
1 change: 1 addition & 0 deletions infra/runtime-accumulator.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -504,3 +504,4 @@ export function _resetRuntimeAccumulatorForTests(options = {}) {
_seenSessionKeys = new Set();
syncGlobals();
}

2 changes: 2 additions & 0 deletions infra/session-tracker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,7 @@ export class SessionTracker {
tokenUsage,
insights: session.insights || null,
status: String(session.status || "completed"),

});
session.accumulatedAt = new Date().toISOString();
return true;
Expand Down Expand Up @@ -1820,3 +1821,4 @@ export function _resetSingleton(nextOptions) {
_instance = new SessionTracker(nextOptions);
}
}

110 changes: 110 additions & 0 deletions lib/session-insights.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,109 @@ function normalizeUsage(value) {
return { input, output, total };
}

function coerceTimestamp(value) {
const text = String(value || "").trim();
if (!text) return null;
const parsed = Date.parse(text);
return Number.isFinite(parsed) ? parsed : null;
}

function buildTurnTimeline(messages = []) {
const turns = [];
let currentTurn = null;

const ensureTurn = (msg, fallbackStartTs = null) => {
if (currentTurn) return currentTurn;
const ts = String(msg?.timestamp || fallbackStartTs || new Date().toISOString());
currentTurn = {
index: turns.length + 1,
startedAt: ts,
endedAt: ts,
durationMs: 0,
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
inputTokens: 0,
outputTokens: 0,
tokenCount: 0,
assistantMessages: 0,
toolCalls: 0,
toolResults: 0,
errors: 0,
preview: "",
};
turns.push(currentTurn);
return currentTurn;
};

const finalizeTurn = (msg) => {
if (!currentTurn) return;
const endTs = String(msg?.timestamp || currentTurn.endedAt || currentTurn.startedAt);
currentTurn.endedAt = endTs;
const startedMs = coerceTimestamp(currentTurn.startedAt);
const endedMs = coerceTimestamp(endTs);
currentTurn.durationMs = startedMs != null && endedMs != null
? Math.max(0, endedMs - startedMs)
: 0;
currentTurn.inputTokens = currentTurn.tokenUsage.inputTokens;
currentTurn.outputTokens = currentTurn.tokenUsage.outputTokens;
currentTurn.tokenCount = currentTurn.tokenUsage.totalTokens;
if (!currentTurn.preview) {
currentTurn.preview = `Turn ${currentTurn.index}`;
}
currentTurn = null;
};

for (const msg of messages) {
if (!msg) continue;
const type = String(msg.type || "").toLowerCase();
const role = String(msg.role || "").toLowerCase();
const content = toText(msg.content).trim();
const usage = normalizeUsage(msg?.meta?.usage) || normalizeUsage(msg?.usage) || null;
const isUser = role === "user";
const isAssistant = role === "assistant" || type === "agent_message" || type === "assistant_message";
const lifecycle = String(msg?.meta?.lifecycle || "").toLowerCase();

if (isUser) {
finalizeTurn(msg);
const turn = ensureTurn(msg);
turn.startedAt = String(msg.timestamp || turn.startedAt);
turn.endedAt = String(msg.timestamp || turn.endedAt);
continue;
}

const turn = ensureTurn(msg);
turn.endedAt = String(msg.timestamp || turn.endedAt || turn.startedAt);

if (usage) {
turn.tokenUsage.inputTokens += usage.input;
turn.tokenUsage.outputTokens += usage.output;
turn.tokenUsage.totalTokens += usage.total;
}
if (isAssistant) {
turn.assistantMessages += 1;
if (!turn.preview && content) {
turn.preview = content.slice(0, 140);
}
} else if (type === "tool_call" && lifecycle !== "started") {
turn.toolCalls += 1;
} else if (type === "tool_result" || type === "tool_output") {
turn.toolResults += 1;
} else if (type === "error" || type === "stream_error") {
turn.errors += 1;
if (!turn.preview && content) turn.preview = content.slice(0, 140);
}

if (lifecycle === "turn_completed") {
finalizeTurn(msg);
}
}

if (currentTurn) {
finalizeTurn({ timestamp: currentTurn.endedAt || currentTurn.startedAt });
}

return turns;
}

export function formatCompactCount(value) {
const n = Number(value || 0);
if (!Number.isFinite(n)) return "0";
Expand Down Expand Up @@ -389,6 +492,7 @@ export function buildSessionInsights(fullSession = null) {
contextWindow,
contextBreakdown,
tokenUsage,
turns: buildTurnTimeline(messages),
activityDiff: {
files: edited.map((entry) => ({
path: entry.path,
Expand Down Expand Up @@ -417,7 +521,13 @@ export function buildSessionInsights(fullSession = null) {
? persisted.contextBreakdown
: derived.contextBreakdown,
tokenUsage: persisted.tokenUsage || derived.tokenUsage,
turns: Array.isArray(persisted.turns) ? persisted.turns : derived.turns,
activityDiff: persisted.activityDiff || derived.activityDiff,
generatedAt: persisted.generatedAt || derived.generatedAt,
};
}





21 changes: 21 additions & 0 deletions server/ui-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12629,6 +12629,25 @@ async function readReplayableAgentRun(attemptId) {
const first = events[0] || null;
const last = events[events.length - 1] || null;
const overview = buildReplayOverview(events);
const turns = [];
let turnIndex = 0;
for (const event of events) {
if (event?.type !== "usage") continue;
turnIndex += 1;
const usage = event?.data?.usage && typeof event.data.usage === "object" ? event.data.usage : {};
const tokenCount = Number(usage.total_tokens ?? usage.totalTokens ?? event?.data?.tokenCount ?? 0) || 0;
const inputTokens = Number(usage.input_tokens ?? usage.inputTokens ?? 0) || 0;
const outputTokens = Number(usage.output_tokens ?? usage.outputTokens ?? 0) || 0;
const durationMs = Number(event?.data?.duration_ms ?? event?.data?.durationMs ?? 0) || 0;
turns.push({
index: turnIndex,
timestamp: event.timestamp || null,
tokenCount,
inputTokens,
outputTokens,
durationMs,
});
}
return {
attemptId: normalizedAttemptId,
taskId: first?.taskId || last?.taskId || null,
Expand All @@ -12641,6 +12660,7 @@ async function readReplayableAgentRun(attemptId) {
: "in_progress",
shortSteps: overview.shortSteps,
totals: overview.totals,
turns,
events,
};
}
Expand Down Expand Up @@ -23662,3 +23682,4 @@ export { getLocalLanIp };




27 changes: 27 additions & 0 deletions site/ui/tabs/agents.js
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,8 @@ function WorkspaceViewer({ agent, onClose }) {
const [expandedEventItems, setExpandedEventItems] = useState(() => new Set());
const [expandedFileItems, setExpandedFileItems] = useState(() => new Set());
const [expandedModelResponse, setExpandedModelResponse] = useState(false);
const [runHistory, setRunHistory] = useState([]);
const [runDetail, setRunDetail] = useState(null);
const logRef = useRef(null);

const query = buildSessionLogQuery([
Expand Down Expand Up @@ -352,6 +354,25 @@ function WorkspaceViewer({ agent, onClose }) {
.catch(() => { if (active) setLogText("(failed to load logs)"); });
};

const fetchRunHistory = () => {
if (!sessionId && !agent.taskId) return;
const query = agent.taskId ? `?limit=10&taskId=${encodeURIComponent(agent.taskId)}` : "?limit=10";
apiFetch(`/api/agent-runs${query}`, { _silent: true })
.then((res) => {
if (!active) return;
const runs = Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : [];
setRunHistory(runs);
const latest = runs[0]?.attemptId;
if (!latest) return;
return apiFetch(`/api/agent-runs/${encodeURIComponent(latest)}`, { _silent: true });
})
.then((res) => {
if (!active || !res) return;
setRunDetail(res?.data ?? res ?? null);
})
Comment on lines +357 to +372
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetchRunHistory polls /api/agent-runs and /api/agent-runs/:id every 5s and stores results in runHistory/runDetail, but those state values are not used anywhere in this file. This adds recurring API load with no user-visible effect; either render the run history/detail UI or stop polling until it’s needed (e.g., fetch on-demand when opening a Runs tab).

Copilot uses AI. Check for mistakes.
.catch(() => {});
};

const fetchContext = () => {
apiFetch(`/api/agent-context?query=${encodeURIComponent(query)}`, { _silent: true })
.then((res) => { if (active) setContextData(res.data ?? res ?? null); })
Expand All @@ -360,9 +381,11 @@ function WorkspaceViewer({ agent, onClose }) {

fetchLogs();
fetchContext();
fetchRunHistory();
const interval = setInterval(() => {
fetchLogs();
fetchContext();
fetchRunHistory();
}, 5000);
return () => { active = false; clearInterval(interval); };
}, [query]);
Expand Down Expand Up @@ -1786,6 +1809,7 @@ export function AgentsTab() {
}}
>
<div class="task-card-header">
<div class="session-turn-chip">Turns ${s.turnCount || 0}</div>
<div>
<div class="task-card-title">
<${StatusDot} status=${s.status || "idle"} />
Expand All @@ -1795,6 +1819,7 @@ export function AgentsTab() {
${s.id || "?"}
${s.taskId ? ` · ${s.taskId}` : ""}
${s.branch ? ` · ${s.branch}` : ""}
· Turns ${s.turnCount || 0}
</div>
</div>
<${Badge} status=${s.status || "idle"} text=${s.status || "idle"} />
Expand Down Expand Up @@ -1873,6 +1898,7 @@ function ContextViewer({ query, sessionId = "", taskId = "", branch = "" }) {
setError(null);
setCtx(null);
fetchContext();
fetchRunHistory();
intervalRef.current = setInterval(fetchContext, 10000);
return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
}, [fetchContext]);
Expand Down Expand Up @@ -2598,3 +2624,4 @@ export function FleetSessionsTab() {
`}
`;
}

44 changes: 44 additions & 0 deletions tests/session-tracker.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,47 @@ describe("session-tracker", () => {
expect(messages[0].type).toBe("agent_message");
expect(messages[0].content).toContain("Done. Changes are applied.");
});
it("builds per-turn token and duration timeline insights", () => {
tracker.startSession("task-1", "Timeline Test");
tracker.recordEvent("task-1", {
role: "user",
content: "Check the failing tests",
timestamp: "2026-03-26T10:00:00.000Z",
});
tracker.recordEvent("task-1", {
type: "tool_call",
content: "read_file(tests/app.test.mjs)",
timestamp: "2026-03-26T10:00:01.000Z",
meta: { toolName: "read_file" },
});
tracker.recordEvent("task-1", {
role: "assistant",
content: "I found the flaky assertion.",
timestamp: "2026-03-26T10:00:04.000Z",
meta: {
usage: { input_tokens: 120, output_tokens: 30, total_tokens: 150 },
},
});
tracker.recordEvent("task-1", {
type: "system",
content: "Turn completed",
timestamp: "2026-03-26T10:00:05.000Z",
meta: { lifecycle: "turn_completed" },
});
Comment on lines +236 to +262
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test feeds recordEvent() objects shaped like already-normalized session messages ({type, content, timestamp, meta}), but SessionTracker.recordEvent only treats events as “direct messages” when they include role; otherwise they go through #normalizeEvent, which ignores content/meta/timestamp for tool_call and always assigns timestamp to new Date(). As a result, toolCalls and especially durationMs: 4000 will not match deterministically. Either adjust the test to use the supported input shapes (e.g., include role or use the SDK event fields name/arguments), or extend recordEvent/#normalizeEvent to accept and preserve normalized message objects with type/content/timestamp/meta.

Copilot uses AI. Check for mistakes.

const session = tracker.getSession("task-1");
expect(session.turnCount).toBeGreaterThanOrEqual(1);
expect(session.insights?.turns).toEqual([
expect.objectContaining({
index: 1,
durationMs: 4000,
inputTokens: 120,
outputTokens: 30,
tokenCount: 150,
toolCalls: 1,
}),
]);
});

it("ignores low-signal stream noise for activity tracking", () => {
tracker.startSession("task-1", "Test");
Expand Down Expand Up @@ -702,3 +743,6 @@ describe("session-tracker", () => {
});
});
});



Loading
Loading