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