FN-8598: preserve legacy task cost badges
Restore cost badges for tasks with valid legacy token totals. - Preserve usage records when optional timestamps and cache-write totals are absent - Use task creation time to satisfy legacy usage timestamp requirements - Cover card badge rendering, unpriced mixed usage, and mobile visibility - Add a patch changeset for the restored badge behavior Files changed: .changeset/fn-8598-cost-badge-fix.md | 7 + .../task-token-usage-serialization.test.ts | 45 +++++++ packages/core/src/task-store/serialization.ts | 16 ++- .../__tests__/TaskCard.cost-badge.test.tsx | 146 +++++++++++++++++++++ .../app/utils/__tests__/taskTokenCost.test.ts | 11 ++ 5 files changed, 221 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-8598 Fusion-Task-Lineage: 83fb4051-8e0f-4ee9-9f00-0e4d5cb8661e Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8598-cost-badge-fix.md
Normal file
7
.changeset/fn-8598-cost-badge-fix.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Restore task-card cost badges for legacy tasks with recorded token usage.
|
||||
category: fix
|
||||
dev: Preserve positive legacy token usage when optional usage metadata is NULL in slim board payloads.
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { rowToTask } from "../task-store/serialization.js";
|
||||
import type { TaskRow } from "../task-store/persistence.js";
|
||||
|
||||
function legacyUsageRow(): TaskRow {
|
||||
return {
|
||||
id: "FN-8598",
|
||||
lineageId: null,
|
||||
title: "Cost badge fixture",
|
||||
description: "",
|
||||
priority: "normal",
|
||||
column: "todo",
|
||||
status: null,
|
||||
currentStep: 0,
|
||||
createdAt: "2026-07-19T08:00:00.000Z",
|
||||
tokenUsageInputTokens: 1_000_000,
|
||||
tokenUsageOutputTokens: 0,
|
||||
tokenUsageCachedTokens: 0,
|
||||
tokenUsageCacheWriteTokens: null,
|
||||
tokenUsageTotalTokens: 1_000_000,
|
||||
tokenUsageFirstUsedAt: null,
|
||||
tokenUsageLastUsedAt: null,
|
||||
tokenUsageModelProvider: "openai",
|
||||
tokenUsageModelId: "gpt-5-mini",
|
||||
tokenUsagePerModel: null,
|
||||
} as TaskRow;
|
||||
}
|
||||
|
||||
describe("rowToTask token usage", () => {
|
||||
it("preserves positive legacy usage when optional timestamps and cache-write fields are NULL", () => {
|
||||
const task = rowToTask(legacyUsageRow());
|
||||
|
||||
expect(task.tokenUsage).toMatchObject({
|
||||
inputTokens: 1_000_000,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalTokens: 1_000_000,
|
||||
firstUsedAt: "2026-07-19T08:00:00.000Z",
|
||||
lastUsedAt: "2026-07-19T08:00:00.000Z",
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-5-mini",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -173,20 +173,28 @@ export function rowToTask(row: TaskRow): Task {
|
||||
|| row.tokenUsageOutputTokens === null
|
||||
|| row.tokenUsageCachedTokens === null
|
||||
|| row.tokenUsageTotalTokens === null
|
||||
|| row.tokenUsageFirstUsedAt === null
|
||||
|| row.tokenUsageLastUsedAt === null
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TaskCardCostBadge 2026-07-19-08:55:
|
||||
Legacy task rows can have NULL usage timestamps and cache-write totals even when their
|
||||
positive token totals are durable. Board list requests use this reconstruction before
|
||||
TaskCard derives its opt-in spend badge, so timestamp metadata must not erase valid usage.
|
||||
Fall back to the task timestamp only to retain the non-null usage contract; cost derivation
|
||||
reads token totals and model identity, never these fallback timestamps.
|
||||
*/
|
||||
const firstUsedAt = row.tokenUsageFirstUsedAt ?? row.tokenUsageLastUsedAt ?? row.createdAt;
|
||||
const lastUsedAt = row.tokenUsageLastUsedAt ?? row.tokenUsageFirstUsedAt ?? row.createdAt;
|
||||
return {
|
||||
inputTokens: row.tokenUsageInputTokens,
|
||||
outputTokens: row.tokenUsageOutputTokens,
|
||||
cachedTokens: row.tokenUsageCachedTokens,
|
||||
cacheWriteTokens: row.tokenUsageCacheWriteTokens ?? 0,
|
||||
totalTokens: row.tokenUsageTotalTokens,
|
||||
firstUsedAt: row.tokenUsageFirstUsedAt,
|
||||
lastUsedAt: row.tokenUsageLastUsedAt,
|
||||
firstUsedAt,
|
||||
lastUsedAt,
|
||||
modelProvider: row.tokenUsageModelProvider ?? undefined,
|
||||
modelId: row.tokenUsageModelId ?? undefined,
|
||||
perModel: fromJson<import("../types.js").TaskTokenUsagePerModel[]>(row.tokenUsagePerModel) ?? undefined,
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { loadAllAppCss } from "../../test/cssFixture";
|
||||
|
||||
vi.mock("../../hooks/useBadgeWebSocket", () => ({
|
||||
useBadgeWebSocket: () => ({
|
||||
badgeUpdates: new Map(),
|
||||
subscribeToBadge: vi.fn(),
|
||||
unsubscribeFromBadge: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../hooks/useTaskDiffStats", () => ({ useTaskDiffStats: () => ({ stats: null, loading: false }) }));
|
||||
vi.mock("../../hooks/useBatchBadgeFetch", () => ({ getFreshBatchData: () => null }));
|
||||
vi.mock("../../hooks/useToast", () => ({ useOptionalToast: () => null, useToast: () => ({ addToast: vi.fn() }) }));
|
||||
vi.mock("../RuntimeFallbackBadge", () => ({ RuntimeFallbackBadge: () => null }));
|
||||
vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: vi.fn(), confirmWithChoice: vi.fn() }) }));
|
||||
vi.mock("../../api", () => ({
|
||||
addressPrFeedback: vi.fn(),
|
||||
fetchTaskDetail: vi.fn(),
|
||||
uploadAttachment: vi.fn(),
|
||||
fetchMission: vi.fn(),
|
||||
fetchAgent: vi.fn(),
|
||||
rebuildTaskSpec: vi.fn(),
|
||||
refreshPrStatus: vi.fn(),
|
||||
fetchWorkflowSettingValues: vi.fn().mockResolvedValue({ stored: {}, effective: {}, orphaned: [] }),
|
||||
}));
|
||||
|
||||
import { TaskCard } from "../TaskCard";
|
||||
import { CostBadgeProvider } from "../../context/CostBadgeContext";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function taskWithUsage(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "FN-8598",
|
||||
title: "Cost badge fixture",
|
||||
description: "",
|
||||
column: "todo",
|
||||
steps: [],
|
||||
dependencies: [],
|
||||
tokenUsage: {
|
||||
inputTokens: 1_000_000,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalTokens: 1_000_000,
|
||||
firstUsedAt: "2026-07-19T08:00:00.000Z",
|
||||
lastUsedAt: "2026-07-19T08:00:00.000Z",
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-5-mini",
|
||||
},
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
describe("TaskCard cost badge", () => {
|
||||
it("renders exactly one derived cost badge for a slim-payload-compatible task inside an enabled provider", () => {
|
||||
const { container } = render(
|
||||
<CostBadgeProvider value={{ enabled: true, pricingOverrides: undefined }}>
|
||||
<TaskCard task={taskWithUsage()} onOpenDetail={noop} addToast={noop} />
|
||||
</CostBadgeProvider>,
|
||||
);
|
||||
|
||||
const badges = container.querySelectorAll(".card-cost-indicator");
|
||||
expect(badges).toHaveLength(1);
|
||||
expect(badges[0]?.textContent).toContain("$0.25");
|
||||
expect(badges[0]).toHaveAttribute("title", "Estimated cost $0.25");
|
||||
});
|
||||
|
||||
it("leaves no badge shell when disabled or when usage is missing or zero", () => {
|
||||
const disabled = render(<TaskCard task={taskWithUsage()} onOpenDetail={noop} addToast={noop} />);
|
||||
expect(disabled.container.querySelector(".card-cost-indicator")).toBeNull();
|
||||
disabled.unmount();
|
||||
|
||||
const missing = render(
|
||||
<CostBadgeProvider value={{ enabled: true }}>
|
||||
<TaskCard task={taskWithUsage({ tokenUsage: undefined })} onOpenDetail={noop} addToast={noop} />
|
||||
</CostBadgeProvider>,
|
||||
);
|
||||
expect(missing.container.querySelector(".card-cost-indicator")).toBeNull();
|
||||
expect(missing.container.querySelector(".card-cost-indicator[aria-label]")).toBeNull();
|
||||
missing.unmount();
|
||||
|
||||
const zero = render(
|
||||
<CostBadgeProvider value={{ enabled: true }}>
|
||||
<TaskCard task={taskWithUsage({ tokenUsage: { ...taskWithUsage().tokenUsage!, totalTokens: 0, inputTokens: 0 } })} onOpenDetail={noop} addToast={noop} />
|
||||
</CostBadgeProvider>,
|
||||
);
|
||||
expect(zero.container.querySelector(".card-cost-indicator")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders once alongside leading files-changed footer content", () => {
|
||||
const { container } = render(
|
||||
<CostBadgeProvider value={{ enabled: true }}>
|
||||
<TaskCard
|
||||
task={taskWithUsage({ column: "in-progress", modifiedFiles: ["packages/dashboard/app/components/TaskCard.tsx"] })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>
|
||||
</CostBadgeProvider>,
|
||||
);
|
||||
|
||||
const badges = container.querySelectorAll(".card-cost-indicator");
|
||||
expect(badges).toHaveLength(1);
|
||||
expect(badges[0]?.closest(".card-footer-row")).not.toBeNull();
|
||||
expect(badges[0]?.closest(".card-footer-row-right")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps unpriced usage guess-free and renders once below Promote", () => {
|
||||
const { container } = render(
|
||||
<CostBadgeProvider value={{ enabled: true }}>
|
||||
<TaskCard
|
||||
task={taskWithUsage({
|
||||
tokenUsage: {
|
||||
...taskWithUsage().tokenUsage!,
|
||||
modelProvider: "unknown",
|
||||
modelId: "no-price",
|
||||
perModel: [
|
||||
{ modelProvider: "openai", modelId: "gpt-5-mini", inputTokens: 1_000_000, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 1_000_000 },
|
||||
{ modelProvider: "unknown", modelId: "no-price", inputTokens: 1, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 1 },
|
||||
],
|
||||
},
|
||||
})}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
onPromote={vi.fn().mockResolvedValue(undefined)}
|
||||
/>
|
||||
</CostBadgeProvider>,
|
||||
);
|
||||
|
||||
const badges = container.querySelectorAll(".card-cost-indicator");
|
||||
expect(badges).toHaveLength(1);
|
||||
expect(badges[0]?.textContent).toBe("—");
|
||||
expect(badges[0]?.closest(".card-promote-cost-row")).not.toBeNull();
|
||||
expect(badges[0]?.closest(".card-footer-row-right")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the shared card cost chip visible at the mobile breakpoint", () => {
|
||||
const css = loadAllAppCss();
|
||||
|
||||
expect(css).toMatch(/@media[^{]*\(max-width:\s*768px\)[^{]*\{[\s\S]*?\.card-time-indicator\s*,\s*\.card-cost-indicator[\s\S]*?height:\s*var\(--card-chip-height-mobile\)/);
|
||||
expect(css).not.toMatch(/@media[^{]*\(max-width:\s*768px\)[^{]*\{[\s\S]*?\.card-cost-indicator\s*\{[^}]*display:\s*none/);
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,17 @@ describe("taskTokenCost", () => {
|
||||
expect(formatCost(totalCostForRows(rows).usd, totalCostForRows(rows).unavailable)).toBe("—");
|
||||
});
|
||||
|
||||
it("keeps mixed priced and unpriced per-model usage unavailable", () => {
|
||||
const total = taskTotalCost(task(usage({
|
||||
perModel: [
|
||||
{ modelProvider: "openai", modelId: "gpt-5-mini", inputTokens: 1_000_000, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 1_000_000 },
|
||||
{ modelProvider: "unknown", modelId: "no-price", inputTokens: 1, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 1 },
|
||||
],
|
||||
})));
|
||||
|
||||
expect(formatCost(total.usd, total.unavailable)).toBe("—");
|
||||
});
|
||||
|
||||
it("does not fabricate cost for zero usage", () => {
|
||||
const zeroTask = task(usage({ modelProvider: "unknown", modelId: "no-price" }));
|
||||
const total = taskTotalCost(zeroTask);
|
||||
|
||||
Reference in New Issue
Block a user