diff --git a/gadget-code/frontend/src/components/SessionPanel.tsx b/gadget-code/frontend/src/components/SessionPanel.tsx
index da6f59e..3f46f62 100644
--- a/gadget-code/frontend/src/components/SessionPanel.tsx
+++ b/gadget-code/frontend/src/components/SessionPanel.tsx
@@ -41,6 +41,7 @@ interface SessionPanelProps {
truncateModelName: (name: string, maxLength?: number) => string;
getSelectedModelCapabilities: () => { hasThinking: boolean } | null;
getSelectedModel: () => AiModel | null;
+ contextWindowUsage: number;
}
export default function SessionPanel({
@@ -82,7 +83,9 @@ export default function SessionPanel({
truncateModelName,
getSelectedModelCapabilities,
getSelectedModel,
+ contextWindowUsage,
}: SessionPanelProps) {
+ const usagePercent = sessionNumCtx > 0 ? (contextWindowUsage / sessionNumCtx) * 100 : 0;
return (
@@ -292,6 +295,33 @@ export default function SessionPanel({
)}
+
+
Context Window Usage
+
+
+
E
+
+
= 80 ? 'bg-red-500' :
+ usagePercent >= 50 ? 'bg-yellow-500' :
+ 'bg-green-500'
+ }`}
+ style={{ width: `${Math.min(usagePercent, 100)}%` }}
+ />
+
+
F
+
+
+
+ {contextWindowUsage.toLocaleString()} tokens
+
+
+ {sessionNumCtx.toLocaleString()} max
+
+
+
+
);
diff --git a/gadget-code/frontend/src/lib/api.ts b/gadget-code/frontend/src/lib/api.ts
index 2d15bd2..e8849b1 100644
--- a/gadget-code/frontend/src/lib/api.ts
+++ b/gadget-code/frontend/src/lib/api.ts
@@ -388,6 +388,7 @@ export interface ChatSession {
selectedModel: string;
reasoningEffort?: string;
numCtx?: number;
+ contextWindowUsage: number;
stats: ChatSessionStats;
pins: Array<{ _id?: string; content: string }>;
}
@@ -401,6 +402,18 @@ export interface ChatTurnStats {
durationLabel: string;
}
+export interface IWorkOrderCompleteStats {
+ masterInputTokens: number;
+ masterOutputTokens: number;
+ masterThinkingTokens: number;
+ toolCallCount: number;
+ durationMs: number;
+ /** Aggregate (master + subagent) totals for session-level accounting */
+ totalInputTokens?: number;
+ totalOutputTokens?: number;
+ totalToolCallCount?: number;
+}
+
export interface ChatTurnPrompts {
user: string;
system?: string;
diff --git a/gadget-code/frontend/src/lib/socket.ts b/gadget-code/frontend/src/lib/socket.ts
index f1eb4f9..0158d9e 100644
--- a/gadget-code/frontend/src/lib/socket.ts
+++ b/gadget-code/frontend/src/lib/socket.ts
@@ -1,6 +1,6 @@
import { createContext } from "react";
import { io, Socket } from "socket.io-client";
-import type { ChatSession } from "./api";
+import type { ChatSession, IWorkOrderCompleteStats } from "./api";
/**
* Web Worker for heartbeat timing — avoids browser tab throttling.
@@ -30,6 +30,7 @@ export interface ServerToClientEvents {
turnId: string,
success: boolean,
message?: string,
+ stats?: IWorkOrderCompleteStats,
) => void;
log: (
timestamp: string,
@@ -90,6 +91,7 @@ export interface SocketEvents {
turnId: string,
success: boolean,
message?: string,
+ stats?: IWorkOrderCompleteStats,
) => void;
"agent:thinking": (data: { agentId: string; thinking: string }) => void;
"agent:response": (data: { agentId: string; chunk: string }) => void;
@@ -228,8 +230,8 @@ class SocketClient {
this.socket.on(
"workOrderComplete",
- (turnId: string, success: boolean, message?: string) => {
- this.emit("workOrderComplete", turnId, success, message);
+ (turnId: string, success: boolean, message?: string, stats?: IWorkOrderCompleteStats) => {
+ this.emit("workOrderComplete", turnId, success, message, stats);
},
);
diff --git a/gadget-code/frontend/src/pages/ChatSessionView.tsx b/gadget-code/frontend/src/pages/ChatSessionView.tsx
index 32179eb..af9c28c 100644
--- a/gadget-code/frontend/src/pages/ChatSessionView.tsx
+++ b/gadget-code/frontend/src/pages/ChatSessionView.tsx
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useContext, useCallback } from 'react';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import { BrushCleaning, Maximize2 } from 'lucide-react';
import { socketClient } from '../lib/socket';
-import { chatSessionApi, projectApi, providerApi, type ChatSession, type ChatTurn, type ChatTurnBlock, ChatTurnStats, ChatSessionMode, type AiProvider, type Project } from '../lib/api';
+import { chatSessionApi, projectApi, providerApi, type ChatSession, type ChatTurn, type ChatTurnBlock, ChatTurnStats, ChatSessionMode, type AiProvider, type Project, type IWorkOrderCompleteStats } from '../lib/api';
import { WorkspaceMode } from '../lib/types';
import SessionPanel from '../components/SessionPanel';
import ProjectPanel from '../components/ProjectPanel';
@@ -686,7 +686,7 @@ export default function ChatSessionView() {
}, 4000);
}, []);
- const handleWorkOrderComplete = useCallback((turnId: string, success: boolean, message?: string) => {
+ const handleWorkOrderComplete = useCallback((turnId: string, success: boolean, message?: string, stats?: IWorkOrderCompleteStats) => {
// Backend has already flushed and persisted all streaming content
// Just clean up frontend streaming state and update status
if (streamingStateRef.current.has(turnId)) {
@@ -734,6 +734,40 @@ export default function ChatSessionView() {
setIsAborting(false);
setIsProcessing(false);
currentTurnIdRef.current = null;
+
+ // Update turn stats from completion event
+ if (stats) {
+ setTurns(prev => prev.map(t => {
+ if (t._id !== turnId) return t;
+ return {
+ ...t,
+ stats: {
+ ...t.stats,
+ inputTokens: stats.masterInputTokens,
+ thinkingTokenCount: stats.masterThinkingTokens,
+ responseTokens: stats.masterOutputTokens,
+ toolCallCount: stats.toolCallCount,
+ durationMs: stats.durationMs,
+ durationLabel: stats.durationMs < 1000
+ ? `${stats.durationMs}ms`
+ : `${(stats.durationMs / 1000).toFixed(1)}s`,
+ },
+ };
+ }));
+
+ // Update session contextWindowUsage for fuel gauge
+ // Uses aggregate totals (master+subagent) when available, falls back to master-only
+ setSession(prev => prev ? {
+ ...prev,
+ contextWindowUsage: (prev.contextWindowUsage ?? 0) + stats.masterInputTokens,
+ stats: {
+ ...prev.stats,
+ inputTokens: prev.stats.inputTokens + (stats.totalInputTokens ?? stats.masterInputTokens),
+ outputTokens: prev.stats.outputTokens + (stats.totalOutputTokens ?? stats.masterOutputTokens),
+ toolCallCount: prev.stats.toolCallCount + (stats.totalToolCallCount ?? stats.toolCallCount),
+ },
+ } : prev);
+ }
}, [showToast]);
const handleWorkspaceModeChanged = useCallback((mode: string) => {
@@ -1441,6 +1475,7 @@ export default function ChatSessionView() {
truncateModelName={truncateModelName}
getSelectedModelCapabilities={getSelectedModelCapabilities}
getSelectedModel={getSelectedModel}
+ contextWindowUsage={session?.contextWindowUsage ?? 0}
/>
{
if (!this.chatSessionId) {
this.log.warn("workOrderComplete event received but no chat session is active");
@@ -303,6 +306,8 @@ export class DroneSession extends SocketSession {
await this.flushBuffer(turnId);
this.streamingBuffers.delete(turnId);
+ let enrichedStats: IWorkOrderCompleteStats | undefined;
+
const turn = await ChatTurn.findById(turnId);
if (turn) {
if (success && message === "aborted") {
@@ -314,20 +319,63 @@ export class DroneSession extends SocketSession {
turn.errorMessage = message;
}
}
+
+ // UPDATE TURN STATS — master agent tokens only
+ if (stats) {
+ turn.stats.inputTokens = stats.masterInputTokens;
+ turn.stats.thinkingTokenCount = stats.masterThinkingTokens;
+ turn.stats.responseTokens = stats.masterOutputTokens;
+ turn.stats.toolCallCount = stats.toolCallCount;
+ turn.stats.durationMs = stats.durationMs;
+ turn.stats.durationLabel = this.formatDurationLabel(stats.durationMs);
+ }
+
await turn.save();
+
+ // COMPUTE AGGREGATE: walk the turn's subagent records for aggregate totals
+ let totalInputTokens = stats?.masterInputTokens ?? 0;
+ let totalOutputTokens = stats?.masterOutputTokens ?? 0;
+ let totalToolCallCount = stats?.toolCallCount ?? 0;
+ if (stats) {
+ for (const tc of turn.toolCalls) {
+ if ((tc as any).subagent?.stats) {
+ const subStats = (tc as any).subagent.stats;
+ totalInputTokens += subStats.inputTokens;
+ totalOutputTokens += subStats.responseTokens + subStats.thinkingTokenCount;
+ totalToolCallCount += subStats.toolCallCount;
+ }
+ }
+ }
+
+ // Build enriched stats with aggregate values for in-memory consumers
+ enrichedStats = stats
+ ? { ...stats, totalInputTokens, totalOutputTokens, totalToolCallCount }
+ : undefined;
+
+ // INCREMENT SESSION STATS — aggregate totals (master + subagents)
+ await ChatSession.updateOne(
+ { _id: this.chatSessionId },
+ { $inc: {
+ "stats.inputTokens": totalInputTokens,
+ "stats.outputTokens": totalOutputTokens,
+ "stats.toolCallCount": totalToolCallCount,
+ // Master-only field for the fuel gauge
+ "contextWindowUsage": stats?.masterInputTokens ?? 0,
+ }},
+ );
}
const codeSession = SocketService.getCodeSessionByChatSessionId(
this.chatSessionId,
);
- codeSession.onWorkOrderComplete(turnId, success, message);
+ codeSession.onWorkOrderComplete(turnId, success, message, enrichedStats);
this.currentTurnId = undefined;
} catch (error) {
// Routing failed - queue to Redis
await MessageQueue.enqueue(this.chatSessionId, {
type: 'workOrderComplete',
- args: [turnId, success, message],
+ args: [turnId, success, message, stats],
timestamp: Date.now(),
});
this.log.debug("queued workOrderComplete message", { chatSessionId: this.chatSessionId });
@@ -343,6 +391,17 @@ export class DroneSession extends SocketSession {
this.streamingBuffers.clear();
}
+ private formatDurationLabel(durationMs: number): string {
+ if (durationMs < 1000) return `${durationMs}ms`;
+ const seconds = Math.floor(durationMs / 1000);
+ const minutes = Math.floor(seconds / 60);
+ const secs = seconds % 60;
+ if (minutes > 0) {
+ return `${minutes}m ${secs}s`;
+ }
+ return `${seconds}.${Math.floor((durationMs % 1000) / 100)}s`;
+ }
+
/**
* Gets or creates a streaming buffer for a turn.
*/
@@ -669,7 +728,8 @@ export class DroneSession extends SocketSession {
codeSession.onWorkOrderComplete(
msg.args[0] as string,
msg.args[1] as boolean,
- msg.args[2] as string,
+ msg.args[2] as string | undefined,
+ msg.args[3] as IWorkOrderCompleteStats | undefined,
);
break;
case 'log':
diff --git a/gadget-code/src/models/chat-session.ts b/gadget-code/src/models/chat-session.ts
index e55ec58..22b44e5 100644
--- a/gadget-code/src/models/chat-session.ts
+++ b/gadget-code/src/models/chat-session.ts
@@ -31,6 +31,7 @@ export const ChatSessionSchema = new Schema({
default: "off",
},
numCtx: { type: Number },
+ contextWindowUsage: { type: Number, default: 0, required: true },
stats: {
turnCount: { type: Number, default: 0, required: true },
toolCallCount: { type: Number, default: 0, required: true },
diff --git a/gadget-code/src/services/chat-session.ts b/gadget-code/src/services/chat-session.ts
index cc82740..31d326a 100644
--- a/gadget-code/src/services/chat-session.ts
+++ b/gadget-code/src/services/chat-session.ts
@@ -131,6 +131,7 @@ class ChatSessionService extends DtpService {
inputTokens: 0,
outputTokens: 0,
},
+ contextWindowUsage: 0,
pins: [],
});
diff --git a/gadget-drone/src/services/agent.test.ts b/gadget-drone/src/services/agent.test.ts
index b0d7d4d..c4bebfd 100644
--- a/gadget-drone/src/services/agent.test.ts
+++ b/gadget-drone/src/services/agent.test.ts
@@ -39,6 +39,7 @@ describe("AgentService", () => {
inputTokens: 0,
outputTokens: 0,
},
+ contextWindowUsage: 0,
pins: [],
};
const workOrder: IAgentWorkOrder = {
@@ -150,6 +151,7 @@ describe("AgentService", () => {
inputTokens: 0,
outputTokens: 0,
},
+ contextWindowUsage: 0,
pins: [],
};
@@ -314,6 +316,7 @@ describe("AgentService", () => {
inputTokens: 0,
outputTokens: 0,
},
+ contextWindowUsage: 0,
pins: [],
};
const rawResponse = "Subagent completed the task successfully.";
diff --git a/gadget-drone/src/services/agent.ts b/gadget-drone/src/services/agent.ts
index a4aea64..b7fd7a0 100644
--- a/gadget-drone/src/services/agent.ts
+++ b/gadget-drone/src/services/agent.ts
@@ -30,6 +30,7 @@ import {
ChatSessionMode,
ChatTurnStatus,
type IProject,
+ type IWorkOrderCompleteStats,
} from "@gadget/api";
import AiService from "./ai.ts";
@@ -58,6 +59,7 @@ import {
ReadSkillTool,
type DroneToolboxEnvironment,
} from "../tools/index.ts";
+
import type { IChatExportData } from "@gadget/ai-toolbox";
export interface IAgentWorkOrder {
@@ -164,8 +166,10 @@ class AgentService extends GadgetService {
const { turn } = workOrder;
let toolCallCount = 0;
- let inputTokens = 0;
- let outputTokens = 0;
+ let masterInputTokens = 0;
+ let masterOutputTokens = 0;
+ let masterThinkingTokens = 0;
+ const startTime = Date.now();
// Build the full message array that grows with each iteration
const messages: IContextChatMessage[] = [];
@@ -258,6 +262,11 @@ class AgentService extends GadgetService {
throw new Error("Model failed to respond (still loading or error)");
}
+ // Accumulate real token counts from the AI API response
+ masterInputTokens += response.stats?.tokenCounts?.input ?? 0;
+ masterOutputTokens += response.stats?.tokenCounts?.response ?? 0;
+ masterThinkingTokens += response.stats?.tokenCounts?.thinking ?? 0;
+
// Process tool calls if present
if (response.toolCalls && response.toolCalls.length > 0) {
continueLoop = true;
@@ -304,6 +313,7 @@ class AgentService extends GadgetService {
agentId: toolCall.callId,
response: responseText,
subagent: subagentData,
+ stats: subagentData?.stats as Record | undefined,
});
} else {
socket.emit("toolCall", toolCall.callId, toolCall.function.name, toolCall.function.arguments, result);
@@ -338,17 +348,30 @@ class AgentService extends GadgetService {
toolName: toolCall.function.name,
});
- inputTokens += Math.ceil(toolCall.function.arguments.length / 4);
- outputTokens += Math.ceil(result.length / 4);
}
}
}
- socket.emit("workOrderComplete", turn._id, true);
+ const durationMs = Date.now() - startTime;
+ const stats: IWorkOrderCompleteStats = {
+ masterInputTokens,
+ masterOutputTokens,
+ masterThinkingTokens,
+ toolCallCount,
+ durationMs,
+ };
+ socket.emit("workOrderComplete", turn._id, true, undefined, stats);
} catch (cause) {
if (cause instanceof Error && cause.name === "AbortError") {
this.log.info("work order aborted by user", { turnId: turn._id });
- socket.emit("workOrderComplete", turn._id, true, "aborted");
+ const abortStats: IWorkOrderCompleteStats = {
+ masterInputTokens,
+ masterOutputTokens,
+ masterThinkingTokens,
+ toolCallCount,
+ durationMs: Date.now() - startTime,
+ };
+ socket.emit("workOrderComplete", turn._id, true, "aborted", abortStats);
return;
}
const msg = cause instanceof Error ? cause.message : String(cause);
@@ -356,7 +379,14 @@ class AgentService extends GadgetService {
turnId: turn._id,
error: msg,
});
- socket.emit("workOrderComplete", turn._id, false, msg);
+ const errorStats: IWorkOrderCompleteStats = {
+ masterInputTokens,
+ masterOutputTokens,
+ masterThinkingTokens,
+ toolCallCount,
+ durationMs: Date.now() - startTime,
+ };
+ socket.emit("workOrderComplete", turn._id, false, msg, errorStats);
throw cause;
} finally {
this.abortController = null;
@@ -740,11 +770,11 @@ class AgentService extends GadgetService {
inputTokens += response.stats?.tokenCounts?.input ?? 0;
if (response.thinking) {
- thinkingTokenCount += Math.ceil(response.thinking.length / 4);
+ thinkingTokenCount += response.stats?.tokenCounts?.thinking ?? 0;
}
if (response.response) {
fullResponse += response.response;
- outputTokens += Math.ceil(response.response.length / 4);
+ outputTokens += response.stats?.tokenCounts?.response ?? 0;
}
if (response.thinking) {
fullThinking = (fullThinking + response.thinking).trim();
@@ -800,8 +830,6 @@ class AgentService extends GadgetService {
toolName: toolCall.function.name,
});
- inputTokens += Math.ceil(toolArgsRaw.length / 4);
- outputTokens += Math.ceil(result.length / 4);
}
}
}
diff --git a/packages/ai/src/openai.ts b/packages/ai/src/openai.ts
index 1dc5a42..6ca947d 100644
--- a/packages/ai/src/openai.ts
+++ b/packages/ai/src/openai.ts
@@ -64,6 +64,12 @@ interface StreamingToolCallAccumulator {
};
}
+interface OpenAiUsage {
+ promptTokens: number;
+ completionTokens: number;
+ reasoningTokens: number;
+}
+
interface OpenAiChatIterationResult {
response: string;
thinking?: string;
@@ -80,6 +86,7 @@ interface OpenAiChatIterationResult {
chunkCount: number;
contentDeltaCount: number;
toolDeltaCount: number;
+ usage?: OpenAiUsage;
}
export class OpenAiApi extends AiApi {
@@ -187,11 +194,6 @@ export class OpenAiApi extends AiApi {
options: IAiGenerateOptions,
streamCallback?: IAiResponseStreamFn,
): Promise {
- await this.log.debug("OpenAiApi.generate called", {
- provider: model.provider.name,
- modelId: model.modelId,
- });
-
if (options.signal?.aborted) {
throw new DOMException("The operation was aborted", "AbortError");
}
@@ -206,6 +208,7 @@ export class OpenAiApi extends AiApi {
{ role: "user" as const, content: options.prompt },
],
stream: true,
+ stream_options: { include_usage: true },
...(model.params.maxCompletionTokens
? { max_completion_tokens: model.params.maxCompletionTokens }
: {}),
@@ -223,12 +226,18 @@ export class OpenAiApi extends AiApi {
let accumulatedResponse = "";
let accumulatedThinking = "";
+ let usage: OpenAiUsage | undefined;
for await (const chunk of response) {
if (options.signal?.aborted) {
throw new DOMException("The operation was aborted", "AbortError");
}
+ // Capture usage from the final streaming chunk
+ if (chunk.usage) {
+ usage = this.extractUsage(chunk.usage);
+ }
+
const delta = chunk.choices[0]?.delta;
if (delta) {
if (delta.content) {
@@ -265,9 +274,9 @@ export class OpenAiApi extends AiApi {
text: numeral(durationMs / 1000).format("hh:mm:ss"),
},
tokenCounts: {
- input: 0,
- response: 0,
- thinking: 0,
+ input: usage?.promptTokens ?? 0,
+ response: usage?.completionTokens ?? 0,
+ thinking: usage?.reasoningTokens ?? 0,
},
},
};
@@ -278,11 +287,6 @@ export class OpenAiApi extends AiApi {
options: IAiChatOptions,
streamCallback?: IAiResponseStreamFn,
): Promise {
- await this.log.debug("OpenAiApi.chat called", {
- provider: model.provider.name,
- modelId: model.modelId,
- });
-
const startTime = Date.now();
const messages = this.buildMessages(options);
const tools = this.buildTools(options);
@@ -295,27 +299,11 @@ export class OpenAiApi extends AiApi {
options.signal,
);
- await this.log.debug("OpenAI chat stream iteration finished", {
- chunkCount: iteration.chunkCount,
- contentDeltaCount: iteration.contentDeltaCount,
- toolDeltaCount: iteration.toolDeltaCount,
- responseLength: iteration.response.length,
- thinkingLength: iteration.thinking?.length || 0,
- toolCallCount: iteration.toolCalls.length,
- finishReason: iteration.finishReason,
- });
-
if (this.isEmptyIteration(iteration)) {
iteration = await this.readNonStreamingChatCompletion(model, messages, tools, options.signal);
if (streamCallback && iteration.response) {
await streamCallback({ type: "response", data: iteration.response });
}
- await this.log.warn("OpenAI stream was empty; used non-streaming fallback", {
- responseLength: iteration.response.length,
- thinkingLength: iteration.thinking?.length || 0,
- toolCallCount: iteration.toolCalls.length,
- finishReason: iteration.finishReason,
- });
}
if (this.isEmptyIteration(iteration)) {
@@ -325,7 +313,7 @@ export class OpenAiApi extends AiApi {
thinking: undefined,
toolCalls: undefined,
toolCallResults: undefined,
- stats: this.buildStats(startTime),
+ stats: this.buildStats(startTime, iteration.usage),
});
}
@@ -334,12 +322,20 @@ export class OpenAiApi extends AiApi {
response: iteration.response,
thinking: iteration.thinking,
toolCalls: iteration.toolCalls.length > 0 ? iteration.toolCalls : undefined,
- stats: this.buildStats(startTime),
+ stats: this.buildStats(startTime, iteration.usage),
};
this.assertNonEmptyChatResponse(finalResponse);
return finalResponse;
}
+ private extractUsage(usage: { prompt_tokens: number; completion_tokens: number; completion_tokens_details?: { reasoning_tokens?: number } }): OpenAiUsage {
+ return {
+ promptTokens: usage.prompt_tokens,
+ completionTokens: usage.completion_tokens,
+ reasoningTokens: usage.completion_tokens_details?.reasoning_tokens ?? 0,
+ };
+ }
+
private buildMessages(options: IAiChatOptions): ChatCompletionMessageParam[] {
const messages: ChatCompletionMessageParam[] = [];
if (options.systemPrompt) {
@@ -401,6 +397,7 @@ export class OpenAiApi extends AiApi {
messages,
tools,
stream: true,
+ stream_options: { include_usage: true },
...(model.params.maxCompletionTokens
? { max_completion_tokens: model.params.maxCompletionTokens }
: {}),
@@ -423,6 +420,7 @@ export class OpenAiApi extends AiApi {
let toolDeltaCount = 0;
let finishReason: string | null | undefined;
const toolCallMap = new Map();
+ let usage: OpenAiUsage | undefined;
for await (const chunk of response) {
if (signal?.aborted) {
@@ -431,6 +429,12 @@ export class OpenAiApi extends AiApi {
chunkCount++;
finishReason = chunk.choices[0]?.finish_reason ?? finishReason;
+
+ // Capture usage from the final streaming chunk
+ if (chunk.usage) {
+ usage = this.extractUsage(chunk.usage);
+ }
+
const delta = chunk.choices[0]?.delta;
if (!delta) continue;
@@ -463,6 +467,7 @@ export class OpenAiApi extends AiApi {
chunkCount,
contentDeltaCount,
toolDeltaCount,
+ usage,
};
}
@@ -495,9 +500,11 @@ export class OpenAiApi extends AiApi {
}
: {}),
}, signal ? { signal } : undefined);
+
const choice = response.choices[0];
const message = choice?.message;
const content = typeof message?.content === "string" ? message.content : "";
+ const usage = response.usage ? this.extractUsage(response.usage) : undefined;
const assistantToolCalls = (message?.tool_calls || [])
.filter((tc) => tc.type === "function")
.map((tc) => ({
@@ -524,6 +531,7 @@ export class OpenAiApi extends AiApi {
chunkCount: 0,
contentDeltaCount: content ? 1 : 0,
toolDeltaCount: assistantToolCalls.length,
+ usage,
};
}
@@ -592,7 +600,10 @@ export class OpenAiApi extends AiApi {
);
}
- private buildStats(startTime: number): IAiChatResponse["stats"] {
+ private buildStats(
+ startTime: number,
+ usage?: OpenAiUsage,
+ ): IAiChatResponse["stats"] {
const seconds = (Date.now() - startTime) / 1000;
return {
duration: {
@@ -600,9 +611,9 @@ export class OpenAiApi extends AiApi {
text: numeral(seconds).format("hh:mm:ss"),
},
tokenCounts: {
- input: 0,
- response: 0,
- thinking: 0,
+ input: usage?.promptTokens ?? 0,
+ response: usage?.completionTokens ?? 0,
+ thinking: usage?.reasoningTokens ?? 0,
},
};
}
diff --git a/packages/api/src/interfaces/chat-session.ts b/packages/api/src/interfaces/chat-session.ts
index da6bbd7..023f651 100644
--- a/packages/api/src/interfaces/chat-session.ts
+++ b/packages/api/src/interfaces/chat-session.ts
@@ -36,6 +36,7 @@ export interface IChatSession {
selectedModel: string;
reasoningEffort?: ReasoningEffort;
numCtx?: number;
+ contextWindowUsage: number;
stats: {
turnCount: number;
toolCallCount: number;
diff --git a/packages/api/src/messages/drone.ts b/packages/api/src/messages/drone.ts
index 3f8a3d4..95fbc50 100644
--- a/packages/api/src/messages/drone.ts
+++ b/packages/api/src/messages/drone.ts
@@ -41,10 +41,23 @@ export type ToolCallMessage = (
response: string,
) => void;
+export interface IWorkOrderCompleteStats {
+ masterInputTokens: number;
+ masterOutputTokens: number;
+ masterThinkingTokens: number;
+ toolCallCount: number;
+ durationMs: number;
+ /** Aggregate (master + subagent) totals for session-level accounting */
+ totalInputTokens?: number;
+ totalOutputTokens?: number;
+ totalToolCallCount?: number;
+}
+
export type WorkOrderCompleteMessage = (
workOrderId: string,
success: boolean,
message?: string,
+ stats?: IWorkOrderCompleteStats,
) => void;
export type RequestCrashRecoveryMessage = (data: {
diff --git a/repairs.md b/repairs.md
new file mode 100644
index 0000000..464edb4
--- /dev/null
+++ b/repairs.md
@@ -0,0 +1,22 @@
+Summary of Changes (6 files, +36/-19 lines)
+Root Cause
+The token-economics commit (00969cf) correctly added subagent token aggregation to the DB write path (drone-session.ts), but the in-memory and frontend paths only used master-only values. This meant:
+-
+During a session: UI showed lower token counts than reality
+-
+After page refresh: numbers jumped up (DB values loaded)
+-
+Session toolCallCount missed subagent tool calls entirely
+Fixes
+1. packages/api/src/messages/drone.ts — Extended IWorkOrderCompleteStats with optional aggregate fields (totalInputTokens?, totalOutputTokens?, totalToolCallCount?)
+2. gadget-code/src/lib/drone-session.ts — Now computes totalToolCallCount (master + subagent tool calls) alongside existing token aggregates, passes enrichedStats (with aggregate fields populated) to codeSession.onWorkOrderComplete()
+3. gadget-code/src/lib/code-session.ts — In-memory session stats use stats.totalInputTokens ?? stats.masterInputTokens (aggregate when available, master fallback)
+4. gadget-code/frontend/src/lib/api.ts — Frontend type matches the shared interface
+5. gadget-code/frontend/src/pages/ChatSessionView.tsx — Frontend session stats update uses aggregate values; also now correctly increments toolCallCount (was previously missing)
+6. gadget-drone/src/services/agent.ts — Three fixes:
+-
+Removed local IWorkOrderCompleteStats redefinition, now imports from @gadget/api
+-
+Master loop token accumulation uses optional chaining (response.stats?.tokenCounts?.input ?? 0) matching the subagent loop
+-
+agent:complete emit now includes stats field from subagent data