diff --git a/packages/core/src/contracts/app.ts b/packages/core/src/contracts/app.ts index e190f811..0fa8afa8 100644 --- a/packages/core/src/contracts/app.ts +++ b/packages/core/src/contracts/app.ts @@ -2280,7 +2280,7 @@ export type AgentAnalysisSubagentRow = { export type AgentAnalysisTraceRunKind = "agent" | "llm" | "route" | "subagent" | "tool"; -export type AgentAnalysisTraceRunStatus = "error" | "success"; +export type AgentAnalysisTraceRunStatus = "error" | "partial" | "success"; export type AgentAnalysisTracePayloadPreview = { kind: "empty" | "json" | "text"; diff --git a/packages/core/src/observability/request-log-store.ts b/packages/core/src/observability/request-log-store.ts index 02605f6d..f5313972 100644 --- a/packages/core/src/observability/request-log-store.ts +++ b/packages/core/src/observability/request-log-store.ts @@ -2579,7 +2579,11 @@ function buildAgentTrace(requests: AnalyzedAgentRequest[]): AgentAnalysisTrace { outputTokens: totals.outputTokens, sessionId, startedAt: isoFromMs(startMs), - status: totals.errorCount > 0 ? "error" : "success", + status: totals.errorCount === 0 + ? "success" + : totals.errorCount === totals.requestCount + ? "error" + : "partial", totalTokens: totals.totalTokens } ]; diff --git a/packages/core/test/integration/observability/request-log-store.test.mjs b/packages/core/test/integration/observability/request-log-store.test.mjs index 9850cb85..5c7d4d82 100644 --- a/packages/core/test/integration/observability/request-log-store.test.mjs +++ b/packages/core/test/integration/observability/request-log-store.test.mjs @@ -987,6 +987,10 @@ test("RequestLogStore analyzes agent sessions and exposes trace payloads", async assert.equal(selected.selectedSession?.trace.toolRunCount, 1); assert.equal(selected.selectedSession?.trace.llmRunCount, 1); assert.equal(selected.selectedSession?.trace.runs.some((run) => run.toolName === "read_file"), true); + assert.equal( + selected.selectedSession?.trace.runs.find((run) => run.id === selected.selectedSession?.trace.rootRunId)?.status, + "success" + ); const inputPayload = await store.getTracePayload({ callId: "call-read", @@ -1010,6 +1014,88 @@ test("RequestLogStore analyzes agent sessions and exposes trace payloads", async } }); +test("RequestLogStore distinguishes partial session failures from failed sessions", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-agent-status-test-")); + try { + const store = new RequestLogStore(path.join(dir, "request-logs.sqlite")); + const baseTime = Date.now() - 5000; + + async function recordAgentRequest({ offsetMs, requestId, sessionId, statusCode }) { + const startedAtMs = baseTime + offsetMs; + await store.record({ + completedAt: new Date(startedAtMs + 100).toISOString(), + durationMs: 100, + ...(statusCode >= 400 ? { error: "upstream request failed" } : {}), + method: "POST", + path: "/v1/chat/completions", + providerName: "test-provider", + providerProtocol: "openai_chat_completions", + requestBody: Buffer.from(JSON.stringify({ + messages: [{ content: "continue task", role: "user" }], + model: "gpt-test", + session_id: sessionId + }), "utf8"), + requestHeaders: { + "content-type": "application/json", + "user-agent": "openai-codex test", + "x-codex-session-id": sessionId + }, + requestId, + responseBodyText: JSON.stringify({ model: "gpt-test" }), + responseHeaders: { "content-type": "application/json" }, + startedAt: new Date(startedAtMs).toISOString(), + statusCode, + url: "http://127.0.0.1:3456/v1/chat/completions" + }); + } + + await recordAgentRequest({ + offsetMs: 0, + requestId: "mixed-failed", + sessionId: "session-mixed", + statusCode: 502 + }); + await recordAgentRequest({ + offsetMs: 1000, + requestId: "mixed-recovered", + sessionId: "session-mixed", + statusCode: 200 + }); + await recordAgentRequest({ + offsetMs: 2000, + requestId: "failed-only", + sessionId: "session-failed", + statusCode: 502 + }); + + const mixed = await store.analyze({ + range: "30d", + sessionAgent: "codex", + sessionId: "session-mixed" + }); + const mixedTrace = mixed.selectedSession?.trace; + assert.equal( + mixedTrace?.runs.find((run) => run.id === mixedTrace.rootRunId)?.status, + "partial" + ); + assert.equal(mixedTrace?.runs.some((run) => run.kind === "llm" && run.status === "error"), true); + assert.equal(mixedTrace?.runs.some((run) => run.kind === "llm" && run.status === "success"), true); + + const failed = await store.analyze({ + range: "30d", + sessionAgent: "codex", + sessionId: "session-failed" + }); + const failedTrace = failed.selectedSession?.trace; + assert.equal( + failedTrace?.runs.find((run) => run.id === failedTrace.rootRunId)?.status, + "error" + ); + } finally { + rmSync(dir, { force: true, recursive: true }); + } +}); + test("RequestLogStore agent analysis cache ratio denominator includes cache tokens when total tokens omit cache", async () => { const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-cache-ratio-test-")); try { diff --git a/packages/ui/src/pages/home/components/dashboard.tsx b/packages/ui/src/pages/home/components/dashboard.tsx index 39ed82a5..4d293566 100644 --- a/packages/ui/src/pages/home/components/dashboard.tsx +++ b/packages/ui/src/pages/home/components/dashboard.tsx @@ -3557,19 +3557,22 @@ function AgentSessionDetailCard({ const agentListSurfaceClassName = "rounded-md border border-border/70 bg-card/70 shadow-[0_1px_2px_rgba(15,23,42,0.04)]"; const agentListFrameClassName = cn("overflow-auto", agentListSurfaceClassName); const agentListTableClassName = "w-full border-collapse text-left text-[11px]"; -const agentListHeadClassName = "sticky top-0 z-10 border-b border-border/70 bg-muted/80 text-muted-foreground backdrop-blur"; +const agentListHeadClassName = "sticky top-0 z-10 border-b border-border/70 bg-muted/80 text-muted-foreground backdrop-blur [&_th]:min-w-[64px] [&_th]:whitespace-nowrap"; const agentListBodyClassName = "divide-y divide-border/50"; function agentListRowClassName({ danger, - selected + selected, + warning }: { danger?: boolean; selected?: boolean; + warning?: boolean; } = {}) { return cn( "bg-card/40 transition-colors hover:bg-muted/30", danger && "bg-rose-500/5 hover:bg-rose-500/10", + warning && "bg-amber-500/5 hover:bg-amber-500/10", selected && "bg-teal-500/10 shadow-[inset_2px_0_0_rgba(20,184,166,0.7)] hover:bg-teal-500/15" ); } @@ -3613,7 +3616,10 @@ function AgentTracePanel({ trace }: { trace: AgentTraceDetail }) { {trace.runs.map((run) => ( - +
@@ -3636,8 +3642,8 @@ function AgentTracePanel({ trace }: { trace: AgentTraceDetail }) {
- - {t(run.status === "error" ? "Error" : "Success")} + + {t(traceRunStatusLabel(run.status))} @@ -4000,6 +4006,7 @@ function traceRunBarStyle(run: AgentAnalysisTraceRun, traceDurationMs: number): function traceRunDotClass(run: AgentAnalysisTraceRun): string { if (run.status === "error") return "bg-rose-500"; + if (run.status === "partial") return "bg-amber-500"; if (run.kind === "agent") return "bg-teal-500"; if (run.kind === "route") return "bg-cyan-500"; if (run.kind === "subagent") return "bg-amber-500"; @@ -4009,6 +4016,7 @@ function traceRunDotClass(run: AgentAnalysisTraceRun): string { function traceRunBarClass(run: AgentAnalysisTraceRun): string { if (run.status === "error") return "bg-rose-500"; + if (run.status === "partial") return "bg-amber-500"; if (run.kind === "agent") return "bg-teal-500"; if (run.kind === "route") return "bg-cyan-500"; if (run.kind === "subagent") return "bg-amber-500"; @@ -4016,6 +4024,18 @@ function traceRunBarClass(run: AgentAnalysisTraceRun): string { return "bg-blue-500"; } +function traceRunStatusBadgeClass(status: AgentAnalysisTraceRun["status"]): string { + if (status === "error") return "border-rose-200 bg-rose-50 text-rose-700"; + if (status === "partial") return "border-amber-200 bg-amber-50 text-amber-700"; + return "border-emerald-200 bg-emerald-50 text-emerald-700"; +} + +function traceRunStatusLabel(status: AgentAnalysisTraceRun["status"]): string { + if (status === "error") return "Error"; + if (status === "partial") return "Partial failure"; + return "Success"; +} + function formatRouteReason(value: string | undefined): string { const trimmed = value?.trim(); if (!trimmed) { @@ -4041,7 +4061,7 @@ function AgentSessionsCard({ ) : (
- +
@@ -4054,6 +4074,8 @@ function AgentSessionsCard({ + + @@ -4077,6 +4099,8 @@ function AgentSessionsCard({ + + diff --git a/packages/ui/src/pages/home/shared/i18n.tsx b/packages/ui/src/pages/home/shared/i18n.tsx index 9ccb8e25..ab08f431 100644 --- a/packages/ui/src/pages/home/shared/i18n.tsx +++ b/packages/ui/src/pages/home/shared/i18n.tsx @@ -1045,6 +1045,7 @@ export const appCopy: Record = { "Model overrides are optional; empty fields keep Claude Code defaults.": "模型设置是可选项;留空会保留 Claude Code 默认设置。", "Display name": "显示名称", "Double click to copy": "双击复制", + "Duration": "持续时间", "Edit": "编辑", "Edit bot": "编辑 Bot", "Edit API Key": "编辑 API 密钥", @@ -1300,6 +1301,7 @@ export const appCopy: Record = { "Profile stopping is only available in the Electron app.": "配置档案停止功能仅在 Electron App 中可用。", "Profile ready": "配置档案已就绪", "Password": "密码", + "Partial failure": "部分失败", "Recent Errors": "最近错误", "Recent Requests": "最近请求", "Refresh": "刷新", @@ -1569,7 +1571,7 @@ export const appCopy: Record = { "Subagent": "子代理", "Subagent Routing": "Subagent 路由", "Subagent calls": "Subagent 调用", - "Subagents": "Subagent", + "Subagents": "子代理", "Success": "成功", "Success rate": "成功率", "System proxy": "系统代理", diff --git a/packages/ui/test/component/observability.test.tsx b/packages/ui/test/component/observability.test.tsx index 96d9adde..cb970919 100644 --- a/packages/ui/test/component/observability.test.tsx +++ b/packages/ui/test/component/observability.test.tsx @@ -2,9 +2,11 @@ import assert from "node:assert/strict"; import test from "node:test"; import * as React from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import type { RequestLogPage } from "@ccr/core/contracts/app.ts"; +import type { AgentAnalysisSessionRow, AgentAnalysisTraceRun, RequestLogPage } from "@ccr/core/contracts/app.ts"; +import { AgentAnalysisView } from "@ccr/ui/pages/home/components/dashboard.tsx"; import { LogsView } from "@ccr/ui/pages/home/components/network-logs.tsx"; import { AppI18nContext, appCopy } from "@ccr/ui/pages/home/shared/i18n.tsx"; +import { createEmptyAgentAnalysis } from "@ccr/ui/pages/home/shared/usage.ts"; const emptyLogPage: RequestLogPage = { generatedAt: "2026-07-23T00:00:00.000Z", @@ -59,3 +61,115 @@ test("LogsView explains filtered empty results and translates page sizes", () => assert.match(html, /25 \/ page/); assert.doesNotMatch(html, /\/ 页/); }); + +test("AgentAnalysisView keeps session headings horizontal and shows cache rate and cost", () => { + const session: AgentAnalysisSessionRow = { + agent: "claude-code", + avgDurationMs: 420, + cacheRatio: 0.375, + cacheReadTokens: 300, + cacheTokens: 300, + cacheWriteTokens: 100, + client: "claude-code", + costUsd: 1.25, + durationMs: 900, + errorCount: 0, + id: "session-cache-cost", + inputTokens: 500, + lastSeenAt: "2026-07-23T00:01:00.000Z", + maxConcurrentRequests: 1, + maxDurationMs: 500, + models: ["claude-sonnet-4"], + outputTokens: 100, + p50DurationMs: 420, + p95DurationMs: 500, + p99DurationMs: 500, + providers: ["anthropic"], + requestCount: 2, + sessionCount: 1, + startedAt: "2026-07-23T00:00:00.000Z", + subagentCallCount: 0, + successRate: 1, + toolCallCount: 1, + topTools: [{ count: 1, name: "Read" }], + totalTokens: 1000 + }; + const snapshot = { + ...createEmptyAgentAnalysis("24h"), + scannedRequestCount: 2, + selectedSession: { + endpoints: [], + errors: [], + models: [], + requests: [], + routes: [], + session, + statusCodes: [], + subagents: [], + tools: [], + totals: session, + trace: { + agent: session.agent, + durationMs: session.durationMs, + endedAt: session.lastSeenAt, + errorCount: 1, + id: `${session.agent}:${session.id}`, + llmRunCount: 0, + maxDepth: 0, + rootRunId: `agent:${session.agent}:${session.id}`, + runCount: 1, + runs: [{ + agent: session.agent, + cacheReadTokens: session.cacheReadTokens, + cacheWriteTokens: session.cacheWriteTokens, + concurrentRequests: session.maxConcurrentRequests, + depth: 0, + durationMs: session.durationMs, + endedAt: session.lastSeenAt, + id: `agent:${session.agent}:${session.id}`, + inputTokens: session.inputTokens, + kind: "agent", + name: "Claude Code session", + offsetMs: 0, + outputTokens: session.outputTokens, + sessionId: session.id, + startedAt: session.startedAt, + status: "partial", + totalTokens: session.totalTokens + } satisfies AgentAnalysisTraceRun], + sessionId: session.id, + startedAt: session.startedAt, + subagentRunCount: 0, + toolRunCount: 0 + } + }, + sessions: [session] + }; + + const html = renderToStaticMarkup( + + undefined} + setAgentFilter={() => undefined} + setRange={() => undefined} + setSelectedSession={() => undefined} + snapshot={snapshot} + /> + + ); + + assert.match(html, /持续时间/); + assert.match(html, /子代理/); + assert.match(html, /缓存率/); + assert.match(html, /38%/); + assert.match(html, /成本/); + assert.match(html, /\$1\.25/); + assert.match(html, /部分失败/); + assert.match(html, /border-amber-200/); + assert.match(html, /min-w-\[64px\]/); + assert.match(html, /whitespace-nowrap/); +});
{t("Session")}{t("Tools")} {t("Subagents")} {t("Errors")}{t("Cache rate")}{t("Cost")} {t("Models")} {t("Providers")} {t("UA")}{formatCompactNumber(session.toolCallCount)} {formatCompactNumber(session.subagentCallCount)} {formatCompactNumber(session.errorCount)}{formatPercent(session.cacheRatio)}{formatUsdCost(session.costUsd)} {session.models.join(", ") || "-"} {session.providers.join(", ") || "-"} {compactUserAgent(session.userAgent)}