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.tokenUsageOutputTokens === null
|
||||||
|| row.tokenUsageCachedTokens === null
|
|| row.tokenUsageCachedTokens === null
|
||||||
|| row.tokenUsageTotalTokens === null
|
|| row.tokenUsageTotalTokens === null
|
||||||
|| row.tokenUsageFirstUsedAt === null
|
|
||||||
|| row.tokenUsageLastUsedAt === null
|
|
||||||
) {
|
) {
|
||||||
return undefined;
|
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 {
|
return {
|
||||||
inputTokens: row.tokenUsageInputTokens,
|
inputTokens: row.tokenUsageInputTokens,
|
||||||
outputTokens: row.tokenUsageOutputTokens,
|
outputTokens: row.tokenUsageOutputTokens,
|
||||||
cachedTokens: row.tokenUsageCachedTokens,
|
cachedTokens: row.tokenUsageCachedTokens,
|
||||||
cacheWriteTokens: row.tokenUsageCacheWriteTokens ?? 0,
|
cacheWriteTokens: row.tokenUsageCacheWriteTokens ?? 0,
|
||||||
totalTokens: row.tokenUsageTotalTokens,
|
totalTokens: row.tokenUsageTotalTokens,
|
||||||
firstUsedAt: row.tokenUsageFirstUsedAt,
|
firstUsedAt,
|
||||||
lastUsedAt: row.tokenUsageLastUsedAt,
|
lastUsedAt,
|
||||||
modelProvider: row.tokenUsageModelProvider ?? undefined,
|
modelProvider: row.tokenUsageModelProvider ?? undefined,
|
||||||
modelId: row.tokenUsageModelId ?? undefined,
|
modelId: row.tokenUsageModelId ?? undefined,
|
||||||
perModel: fromJson<import("../types.js").TaskTokenUsagePerModel[]>(row.tokenUsagePerModel) ?? 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("—");
|
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", () => {
|
it("does not fabricate cost for zero usage", () => {
|
||||||
const zeroTask = task(usage({ modelProvider: "unknown", modelId: "no-price" }));
|
const zeroTask = task(usage({ modelProvider: "unknown", modelId: "no-price" }));
|
||||||
const total = taskTotalCost(zeroTask);
|
const total = taskTotalCost(zeroTask);
|
||||||
|
|||||||
Reference in New Issue
Block a user