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:
gsxdsm
2026-07-26 09:06:41 -07:00
parent 795a38c018
commit d4aa79b66c
5 changed files with 221 additions and 4 deletions

View 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.

View File

@@ -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",
});
});
});

View File

@@ -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,

View File

@@ -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/);
});
});

View File

@@ -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);