refactor(core): extract shared usage, auth, and display helpers

This commit is contained in:
Peter Steinberger
2026-03-02 08:52:46 +00:00
parent e427826fcf
commit d358b3ac88
11 changed files with 356 additions and 259 deletions

View File

@@ -51,6 +51,18 @@ export function normalizeVerb(value?: string): string | undefined {
return trimmed.replace(/_/g, " "); return trimmed.replace(/_/g, " ");
} }
export function resolveActionArg(args: unknown): string | undefined {
if (!args || typeof args !== "object") {
return undefined;
}
const actionRaw = (args as Record<string, unknown>).action;
if (typeof actionRaw !== "string") {
return undefined;
}
const action = actionRaw.trim();
return action || undefined;
}
export function coerceDisplayValue( export function coerceDisplayValue(
value: unknown, value: unknown,
opts: CoerceDisplayValueOptions = {}, opts: CoerceDisplayValueOptions = {},
@@ -1118,3 +1130,80 @@ export function resolveDetailFromKeys(
.map((entry) => `${entry.label} ${entry.value}`) .map((entry) => `${entry.label} ${entry.value}`)
.join(" · "); .join(" · ");
} }
export function resolveToolVerbAndDetail(params: {
toolKey: string;
args?: unknown;
meta?: string;
action?: string;
spec?: ToolDisplaySpec;
fallbackDetailKeys?: string[];
detailMode: "first" | "summary";
detailCoerce?: CoerceDisplayValueOptions;
detailMaxEntries?: number;
detailFormatKey?: (raw: string) => string;
}): { verb?: string; detail?: string } {
const actionSpec = resolveActionSpec(params.spec, params.action);
const fallbackVerb =
params.toolKey === "web_search"
? "search"
: params.toolKey === "web_fetch"
? "fetch"
: params.toolKey.replace(/_/g, " ").replace(/\./g, " ");
const verb = normalizeVerb(actionSpec?.label ?? params.action ?? fallbackVerb);
let detail: string | undefined;
if (params.toolKey === "exec") {
detail = resolveExecDetail(params.args);
}
if (!detail && params.toolKey === "read") {
detail = resolveReadDetail(params.args);
}
if (
!detail &&
(params.toolKey === "write" || params.toolKey === "edit" || params.toolKey === "attach")
) {
detail = resolveWriteDetail(params.toolKey, params.args);
}
if (!detail && params.toolKey === "web_search") {
detail = resolveWebSearchDetail(params.args);
}
if (!detail && params.toolKey === "web_fetch") {
detail = resolveWebFetchDetail(params.args);
}
const detailKeys =
actionSpec?.detailKeys ?? params.spec?.detailKeys ?? params.fallbackDetailKeys ?? [];
if (!detail && detailKeys.length > 0) {
detail = resolveDetailFromKeys(params.args, detailKeys, {
mode: params.detailMode,
coerce: params.detailCoerce,
maxEntries: params.detailMaxEntries,
formatKey: params.detailFormatKey,
});
}
if (!detail && params.meta) {
detail = params.meta;
}
return { verb, detail };
}
export function formatToolDetailText(
detail: string | undefined,
opts: { prefixWithWith?: boolean } = {},
): string | undefined {
if (!detail) {
return undefined;
}
const normalized = detail.includes(" · ")
? detail
.split(" · ")
.map((part) => part.trim())
.filter((part) => part.length > 0)
.join(", ")
: detail;
if (!normalized) {
return undefined;
}
return opts.prefixWithWith ? `with ${normalized}` : normalized;
}

View File

@@ -2,16 +2,11 @@ import { redactToolDetail } from "../logging/redact.js";
import { shortenHomeInString } from "../utils.js"; import { shortenHomeInString } from "../utils.js";
import { import {
defaultTitle, defaultTitle,
formatToolDetailText,
formatDetailKey, formatDetailKey,
normalizeToolName, normalizeToolName,
normalizeVerb, resolveActionArg,
resolveActionSpec, resolveToolVerbAndDetail,
resolveDetailFromKeys,
resolveExecDetail,
resolveReadDetail,
resolveWebFetchDetail,
resolveWebSearchDetail,
resolveWriteDetail,
type ToolDisplaySpec as ToolDisplaySpecBase, type ToolDisplaySpec as ToolDisplaySpecBase,
} from "./tool-display-common.js"; } from "./tool-display-common.js";
import TOOL_DISPLAY_JSON from "./tool-display.json" with { type: "json" }; import TOOL_DISPLAY_JSON from "./tool-display.json" with { type: "json" };
@@ -69,51 +64,18 @@ export function resolveToolDisplay(params: {
const emoji = spec?.emoji ?? FALLBACK.emoji ?? "🧩"; const emoji = spec?.emoji ?? FALLBACK.emoji ?? "🧩";
const title = spec?.title ?? defaultTitle(name); const title = spec?.title ?? defaultTitle(name);
const label = spec?.label ?? title; const label = spec?.label ?? title;
const actionRaw = const action = resolveActionArg(params.args);
params.args && typeof params.args === "object" let { verb, detail } = resolveToolVerbAndDetail({
? ((params.args as Record<string, unknown>).action as string | undefined) toolKey: key,
: undefined; args: params.args,
const action = typeof actionRaw === "string" ? actionRaw.trim() : undefined; meta: params.meta,
const actionSpec = resolveActionSpec(spec, action); action,
const fallbackVerb = spec,
key === "web_search" fallbackDetailKeys: FALLBACK.detailKeys,
? "search" detailMode: "summary",
: key === "web_fetch" detailMaxEntries: MAX_DETAIL_ENTRIES,
? "fetch" detailFormatKey: (raw) => formatDetailKey(raw, DETAIL_LABEL_OVERRIDES),
: key.replace(/_/g, " ").replace(/\./g, " "); });
const verb = normalizeVerb(actionSpec?.label ?? action ?? fallbackVerb);
let detail: string | undefined;
if (key === "exec") {
detail = resolveExecDetail(params.args);
}
if (!detail && key === "read") {
detail = resolveReadDetail(params.args);
}
if (!detail && (key === "write" || key === "edit" || key === "attach")) {
detail = resolveWriteDetail(key, params.args);
}
if (!detail && key === "web_search") {
detail = resolveWebSearchDetail(params.args);
}
if (!detail && key === "web_fetch") {
detail = resolveWebFetchDetail(params.args);
}
const detailKeys = actionSpec?.detailKeys ?? spec?.detailKeys ?? FALLBACK.detailKeys ?? [];
if (!detail && detailKeys.length > 0) {
detail = resolveDetailFromKeys(params.args, detailKeys, {
mode: "summary",
maxEntries: MAX_DETAIL_ENTRIES,
formatKey: (raw) => formatDetailKey(raw, DETAIL_LABEL_OVERRIDES),
});
}
if (!detail && params.meta) {
detail = params.meta;
}
if (detail) { if (detail) {
detail = shortenHomeInString(detail); detail = shortenHomeInString(detail);
@@ -131,18 +93,7 @@ export function resolveToolDisplay(params: {
export function formatToolDetail(display: ToolDisplay): string | undefined { export function formatToolDetail(display: ToolDisplay): string | undefined {
const detailRaw = display.detail ? redactToolDetail(display.detail) : undefined; const detailRaw = display.detail ? redactToolDetail(display.detail) : undefined;
if (!detailRaw) { return formatToolDetailText(detailRaw, { prefixWithWith: true });
return undefined;
}
if (detailRaw.includes(" · ")) {
const compact = detailRaw
.split(" · ")
.map((part) => part.trim())
.filter((part) => part.length > 0)
.join(", ");
return compact ? `with ${compact}` : undefined;
}
return detailRaw;
} }
export function formatToolSummary(display: ToolDisplay): string { export function formatToolSummary(display: ToolDisplay): string {

View File

@@ -4,17 +4,13 @@ import {
resolveSessionFilePath, resolveSessionFilePath,
resolveSessionFilePathOptions, resolveSessionFilePathOptions,
} from "../../config/sessions/paths.js"; } from "../../config/sessions/paths.js";
import type { SessionEntry, SessionSystemPromptReport } from "../../config/sessions/types.js"; import type { SessionEntry } from "../../config/sessions/types.js";
import { loadProviderUsageSummary } from "../../infra/provider-usage.js"; import { loadProviderUsageSummary } from "../../infra/provider-usage.js";
import type { import type {
CostUsageSummary, CostUsageSummary,
SessionCostSummary,
SessionDailyLatency,
SessionDailyModelUsage, SessionDailyModelUsage,
SessionMessageCounts, SessionMessageCounts,
SessionLatencyStats,
SessionModelUsage, SessionModelUsage,
SessionToolUsage,
} from "../../infra/session-cost-usage.js"; } from "../../infra/session-cost-usage.js";
import { import {
loadCostUsageSummary, loadCostUsageSummary,
@@ -24,7 +20,16 @@ import {
type DiscoveredSession, type DiscoveredSession,
} from "../../infra/session-cost-usage.js"; } from "../../infra/session-cost-usage.js";
import { parseAgentSessionKey } from "../../routing/session-key.js"; import { parseAgentSessionKey } from "../../routing/session-key.js";
import { buildUsageAggregateTail } from "../../shared/usage-aggregates.js"; import {
buildUsageAggregateTail,
mergeUsageDailyLatency,
mergeUsageLatency,
} from "../../shared/usage-aggregates.js";
import type {
SessionUsageEntry,
SessionsUsageAggregates,
SessionsUsageResult,
} from "../../shared/usage-types.js";
import { import {
ErrorCodes, ErrorCodes,
errorShape, errorShape,
@@ -340,60 +345,7 @@ export const __test = {
costUsageCache, costUsageCache,
}; };
export type SessionUsageEntry = { export type { SessionUsageEntry, SessionsUsageAggregates, SessionsUsageResult };
key: string;
label?: string;
sessionId?: string;
updatedAt?: number;
agentId?: string;
channel?: string;
chatType?: string;
origin?: {
label?: string;
provider?: string;
surface?: string;
chatType?: string;
from?: string;
to?: string;
accountId?: string;
threadId?: string | number;
};
modelOverride?: string;
providerOverride?: string;
modelProvider?: string;
model?: string;
usage: SessionCostSummary | null;
contextWeight?: SessionSystemPromptReport | null;
};
export type SessionsUsageAggregates = {
messages: SessionMessageCounts;
tools: SessionToolUsage;
byModel: SessionModelUsage[];
byProvider: SessionModelUsage[];
byAgent: Array<{ agentId: string; totals: CostUsageSummary["totals"] }>;
byChannel: Array<{ channel: string; totals: CostUsageSummary["totals"] }>;
latency?: SessionLatencyStats;
dailyLatency?: SessionDailyLatency[];
modelDaily?: SessionDailyModelUsage[];
daily: Array<{
date: string;
tokens: number;
cost: number;
messages: number;
toolCalls: number;
errors: number;
}>;
};
export type SessionsUsageResult = {
updatedAt: number;
startDate: string;
endDate: string;
sessions: SessionUsageEntry[];
totals: CostUsageSummary["totals"];
aggregates: SessionsUsageAggregates;
};
export const usageHandlers: GatewayRequestHandlers = { export const usageHandlers: GatewayRequestHandlers = {
"usage.status": async ({ respond }) => { "usage.status": async ({ respond }) => {
@@ -704,35 +656,8 @@ export const usageHandlers: GatewayRequestHandlers = {
} }
} }
if (usage.latency) { mergeUsageLatency(latencyTotals, usage.latency);
const { count, avgMs, minMs, maxMs, p95Ms } = usage.latency; mergeUsageDailyLatency(dailyLatencyMap, usage.dailyLatency);
if (count > 0) {
latencyTotals.count += count;
latencyTotals.sum += avgMs * count;
latencyTotals.min = Math.min(latencyTotals.min, minMs);
latencyTotals.max = Math.max(latencyTotals.max, maxMs);
latencyTotals.p95Max = Math.max(latencyTotals.p95Max, p95Ms);
}
}
if (usage.dailyLatency) {
for (const day of usage.dailyLatency) {
const existing = dailyLatencyMap.get(day.date) ?? {
date: day.date,
count: 0,
sum: 0,
min: Number.POSITIVE_INFINITY,
max: 0,
p95Max: 0,
};
existing.count += day.count;
existing.sum += day.avgMs * day.count;
existing.min = Math.min(existing.min, day.minMs);
existing.max = Math.max(existing.max, day.maxMs);
existing.p95Max = Math.max(existing.p95Max, day.p95Ms);
dailyLatencyMap.set(day.date, existing);
}
}
if (usage.dailyModelUsage) { if (usage.dailyModelUsage) {
for (const entry of usage.dailyModelUsage) { for (const entry of usage.dailyModelUsage) {

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import { emitAgentEvent, registerAgentRunContext } from "../infra/agent-events.js"; import { emitAgentEvent, registerAgentRunContext } from "../infra/agent-events.js";
import { extractFirstTextBlock } from "../shared/chat-message-content.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { import {
connectOk, connectOk,
@@ -290,23 +291,8 @@ describe("gateway server chat", () => {
}); });
expect(defaultRes.ok).toBe(true); expect(defaultRes.ok).toBe(true);
const defaultMsgs = defaultRes.payload?.messages ?? []; const defaultMsgs = defaultRes.payload?.messages ?? [];
const firstContentText = (msg: unknown): string | undefined => {
if (!msg || typeof msg !== "object") {
return undefined;
}
const content = (msg as { content?: unknown }).content;
if (!Array.isArray(content) || content.length === 0) {
return undefined;
}
const first = content[0];
if (!first || typeof first !== "object") {
return undefined;
}
const text = (first as { text?: unknown }).text;
return typeof text === "string" ? text : undefined;
};
expect(defaultMsgs.length).toBe(200); expect(defaultMsgs.length).toBe(200);
expect(firstContentText(defaultMsgs[0])).toBe("m100"); expect(extractFirstTextBlock(defaultMsgs[0])).toBe("m100");
} finally { } finally {
testState.agentConfig = undefined; testState.agentConfig = undefined;
testState.sessionStorePath = undefined; testState.sessionStorePath = undefined;

View File

@@ -2,11 +2,12 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { resolveStateDir } from "../config/paths.js"; import { resolveStateDir } from "../config/paths.js";
import { import {
clearDeviceAuthTokenFromStore,
type DeviceAuthEntry, type DeviceAuthEntry,
type DeviceAuthStore, loadDeviceAuthTokenFromStore,
normalizeDeviceAuthRole, storeDeviceAuthTokenInStore,
normalizeDeviceAuthScopes, } from "../shared/device-auth-store.js";
} from "../shared/device-auth.js"; import type { DeviceAuthStore } from "../shared/device-auth.js";
const DEVICE_AUTH_FILE = "device-auth.json"; const DEVICE_AUTH_FILE = "device-auth.json";
@@ -49,19 +50,11 @@ export function loadDeviceAuthToken(params: {
env?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv;
}): DeviceAuthEntry | null { }): DeviceAuthEntry | null {
const filePath = resolveDeviceAuthPath(params.env); const filePath = resolveDeviceAuthPath(params.env);
const store = readStore(filePath); return loadDeviceAuthTokenFromStore({
if (!store) { adapter: { readStore: () => readStore(filePath), writeStore: (_store) => {} },
return null; deviceId: params.deviceId,
} role: params.role,
if (store.deviceId !== params.deviceId) { });
return null;
}
const role = normalizeDeviceAuthRole(params.role);
const entry = store.tokens[role];
if (!entry || typeof entry.token !== "string") {
return null;
}
return entry;
} }
export function storeDeviceAuthToken(params: { export function storeDeviceAuthToken(params: {
@@ -72,25 +65,16 @@ export function storeDeviceAuthToken(params: {
env?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv;
}): DeviceAuthEntry { }): DeviceAuthEntry {
const filePath = resolveDeviceAuthPath(params.env); const filePath = resolveDeviceAuthPath(params.env);
const existing = readStore(filePath); return storeDeviceAuthTokenInStore({
const role = normalizeDeviceAuthRole(params.role); adapter: {
const next: DeviceAuthStore = { readStore: () => readStore(filePath),
version: 1, writeStore: (store) => writeStore(filePath, store),
},
deviceId: params.deviceId, deviceId: params.deviceId,
tokens: role: params.role,
existing && existing.deviceId === params.deviceId && existing.tokens
? { ...existing.tokens }
: {},
};
const entry: DeviceAuthEntry = {
token: params.token, token: params.token,
role, scopes: params.scopes,
scopes: normalizeDeviceAuthScopes(params.scopes), });
updatedAtMs: Date.now(),
};
next.tokens[role] = entry;
writeStore(filePath, next);
return entry;
} }
export function clearDeviceAuthToken(params: { export function clearDeviceAuthToken(params: {
@@ -99,19 +83,12 @@ export function clearDeviceAuthToken(params: {
env?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv;
}): void { }): void {
const filePath = resolveDeviceAuthPath(params.env); const filePath = resolveDeviceAuthPath(params.env);
const store = readStore(filePath); clearDeviceAuthTokenFromStore({
if (!store || store.deviceId !== params.deviceId) { adapter: {
return; readStore: () => readStore(filePath),
} writeStore: (store) => writeStore(filePath, store),
const role = normalizeDeviceAuthRole(params.role); },
if (!store.tokens[role]) { deviceId: params.deviceId,
return; role: params.role,
} });
const next: DeviceAuthStore = {
version: 1,
deviceId: store.deviceId,
tokens: { ...store.tokens },
};
delete next.tokens[role];
writeStore(filePath, next);
} }

View File

@@ -1,27 +1,3 @@
declare module "../../scripts/run-node.mjs" {
export const runNodeWatchedPaths: string[];
export function runNodeMain(params?: {
spawn?: (
cmd: string,
args: string[],
options: unknown,
) => {
on: (
event: "exit",
cb: (code: number | null, signal: string | null) => void,
) => void | undefined;
};
spawnSync?: unknown;
fs?: unknown;
stderr?: { write: (value: string) => void };
execPath?: string;
cwd?: string;
args?: string[];
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
}): Promise<number>;
}
declare module "../../scripts/watch-node.mjs" { declare module "../../scripts/watch-node.mjs" {
export function runWatchMain(params?: { export function runWatchMain(params?: {
spawn?: ( spawn?: (

View File

@@ -0,0 +1,15 @@
export function extractFirstTextBlock(message: unknown): string | undefined {
if (!message || typeof message !== "object") {
return undefined;
}
const content = (message as { content?: unknown }).content;
if (!Array.isArray(content) || content.length === 0) {
return undefined;
}
const first = content[0];
if (!first || typeof first !== "object") {
return undefined;
}
const text = (first as { text?: unknown }).text;
return typeof text === "string" ? text : undefined;
}

View File

@@ -0,0 +1,79 @@
import {
type DeviceAuthEntry,
type DeviceAuthStore,
normalizeDeviceAuthRole,
normalizeDeviceAuthScopes,
} from "./device-auth.js";
export type { DeviceAuthEntry, DeviceAuthStore } from "./device-auth.js";
export type DeviceAuthStoreAdapter = {
readStore: () => DeviceAuthStore | null;
writeStore: (store: DeviceAuthStore) => void;
};
export function loadDeviceAuthTokenFromStore(params: {
adapter: DeviceAuthStoreAdapter;
deviceId: string;
role: string;
}): DeviceAuthEntry | null {
const store = params.adapter.readStore();
if (!store || store.deviceId !== params.deviceId) {
return null;
}
const role = normalizeDeviceAuthRole(params.role);
const entry = store.tokens[role];
if (!entry || typeof entry.token !== "string") {
return null;
}
return entry;
}
export function storeDeviceAuthTokenInStore(params: {
adapter: DeviceAuthStoreAdapter;
deviceId: string;
role: string;
token: string;
scopes?: string[];
}): DeviceAuthEntry {
const role = normalizeDeviceAuthRole(params.role);
const existing = params.adapter.readStore();
const next: DeviceAuthStore = {
version: 1,
deviceId: params.deviceId,
tokens:
existing && existing.deviceId === params.deviceId && existing.tokens
? { ...existing.tokens }
: {},
};
const entry: DeviceAuthEntry = {
token: params.token,
role,
scopes: normalizeDeviceAuthScopes(params.scopes),
updatedAtMs: Date.now(),
};
next.tokens[role] = entry;
params.adapter.writeStore(next);
return entry;
}
export function clearDeviceAuthTokenFromStore(params: {
adapter: DeviceAuthStoreAdapter;
deviceId: string;
role: string;
}): void {
const store = params.adapter.readStore();
if (!store || store.deviceId !== params.deviceId) {
return;
}
const role = normalizeDeviceAuthRole(params.role);
if (!store.tokens[role]) {
return;
}
const next: DeviceAuthStore = {
version: 1,
deviceId: store.deviceId,
tokens: { ...store.tokens },
};
delete next.tokens[role];
params.adapter.writeStore(next);
}

View File

@@ -19,6 +19,52 @@ type DailyLike = {
date: string; date: string;
}; };
type LatencyLike = {
count: number;
avgMs: number;
minMs: number;
maxMs: number;
p95Ms: number;
};
type DailyLatencyInput = LatencyLike & { date: string };
export function mergeUsageLatency(
totals: LatencyTotalsLike,
latency: LatencyLike | undefined,
): void {
if (!latency || latency.count <= 0) {
return;
}
totals.count += latency.count;
totals.sum += latency.avgMs * latency.count;
totals.min = Math.min(totals.min, latency.minMs);
totals.max = Math.max(totals.max, latency.maxMs);
totals.p95Max = Math.max(totals.p95Max, latency.p95Ms);
}
export function mergeUsageDailyLatency(
dailyLatencyMap: Map<string, DailyLatencyLike>,
dailyLatency?: DailyLatencyInput[] | null,
): void {
for (const day of dailyLatency ?? []) {
const existing = dailyLatencyMap.get(day.date) ?? {
date: day.date,
count: 0,
sum: 0,
min: Number.POSITIVE_INFINITY,
max: 0,
p95Max: 0,
};
existing.count += day.count;
existing.sum += day.avgMs * day.count;
existing.min = Math.min(existing.min, day.minMs);
existing.max = Math.max(existing.max, day.maxMs);
existing.p95Max = Math.max(existing.p95Max, day.p95Ms);
dailyLatencyMap.set(day.date, existing);
}
}
export function buildUsageAggregateTail< export function buildUsageAggregateTail<
TTotals extends { totalCost: number }, TTotals extends { totalCost: number },
TDaily extends DailyLike, TDaily extends DailyLike,

66
src/shared/usage-types.ts Normal file
View File

@@ -0,0 +1,66 @@
import type { SessionSystemPromptReport } from "../config/sessions/types.js";
import type {
CostUsageSummary,
SessionCostSummary,
SessionDailyLatency,
SessionDailyModelUsage,
SessionLatencyStats,
SessionMessageCounts,
SessionModelUsage,
SessionToolUsage,
} from "../infra/session-cost-usage.js";
export type SessionUsageEntry = {
key: string;
label?: string;
sessionId?: string;
updatedAt?: number;
agentId?: string;
channel?: string;
chatType?: string;
origin?: {
label?: string;
provider?: string;
surface?: string;
chatType?: string;
from?: string;
to?: string;
accountId?: string;
threadId?: string | number;
};
modelOverride?: string;
providerOverride?: string;
modelProvider?: string;
model?: string;
usage: SessionCostSummary | null;
contextWeight?: SessionSystemPromptReport | null;
};
export type SessionsUsageAggregates = {
messages: SessionMessageCounts;
tools: SessionToolUsage;
byModel: SessionModelUsage[];
byProvider: SessionModelUsage[];
byAgent: Array<{ agentId: string; totals: CostUsageSummary["totals"] }>;
byChannel: Array<{ channel: string; totals: CostUsageSummary["totals"] }>;
latency?: SessionLatencyStats;
dailyLatency?: SessionDailyLatency[];
modelDaily?: SessionDailyModelUsage[];
daily: Array<{
date: string;
tokens: number;
cost: number;
messages: number;
toolCalls: number;
errors: number;
}>;
};
export type SessionsUsageResult = {
updatedAt: number;
startDate: string;
endDate: string;
sessions: SessionUsageEntry[];
totals: CostUsageSummary["totals"];
aggregates: SessionsUsageAggregates;
};

View File

@@ -8,9 +8,12 @@ import path from "node:path";
import { GatewayClient } from "../../src/gateway/client.js"; import { GatewayClient } from "../../src/gateway/client.js";
import { connectGatewayClient } from "../../src/gateway/test-helpers.e2e.js"; import { connectGatewayClient } from "../../src/gateway/test-helpers.e2e.js";
import { loadOrCreateDeviceIdentity } from "../../src/infra/device-identity.js"; import { loadOrCreateDeviceIdentity } from "../../src/infra/device-identity.js";
import { extractFirstTextBlock } from "../../src/shared/chat-message-content.js";
import { sleep } from "../../src/utils.js"; import { sleep } from "../../src/utils.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../src/utils/message-channel.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../src/utils/message-channel.js";
export { extractFirstTextBlock };
type NodeListPayload = { type NodeListPayload = {
nodes?: Array<{ nodeId?: string; connected?: boolean; paired?: boolean }>; nodes?: Array<{ nodeId?: string; connected?: boolean; paired?: boolean }>;
}; };
@@ -358,22 +361,6 @@ export async function waitForNodeStatus(
throw new Error(`timeout waiting for node status for ${nodeId}`); throw new Error(`timeout waiting for node status for ${nodeId}`);
} }
export function extractFirstTextBlock(message: unknown): string | undefined {
if (!message || typeof message !== "object") {
return undefined;
}
const content = (message as { content?: unknown }).content;
if (!Array.isArray(content) || content.length === 0) {
return undefined;
}
const first = content[0];
if (!first || typeof first !== "object") {
return undefined;
}
const text = (first as { text?: unknown }).text;
return typeof text === "string" ? text : undefined;
}
export async function waitForChatFinalEvent(params: { export async function waitForChatFinalEvent(params: {
events: ChatEventPayload[]; events: ChatEventPayload[];
runId: string; runId: string;