Skip to content
Open
Show file tree
Hide file tree
Changes from all 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();
}

3 changes: 2 additions & 1 deletion infra/session-tracker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1012,7 +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 +1820,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