From d4aa79b66cbe8a95150ceefa6f9996d4134e7490 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 26 Jul 2026 09:06:41 -0700 Subject: [PATCH] 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) --- .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(-) create mode 100644 .changeset/fn-8598-cost-badge-fix.md create mode 100644 packages/core/src/__tests__/task-token-usage-serialization.test.ts create mode 100644 packages/dashboard/app/components/__tests__/TaskCard.cost-badge.test.tsx diff --git a/.changeset/fn-8598-cost-badge-fix.md b/.changeset/fn-8598-cost-badge-fix.md new file mode 100644 index 0000000000..84d070e66a --- /dev/null +++ b/.changeset/fn-8598-cost-badge-fix.md @@ -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. diff --git a/packages/core/src/__tests__/task-token-usage-serialization.test.ts b/packages/core/src/__tests__/task-token-usage-serialization.test.ts new file mode 100644 index 0000000000..6d403bf931 --- /dev/null +++ b/packages/core/src/__tests__/task-token-usage-serialization.test.ts @@ -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", + }); + }); +}); diff --git a/packages/core/src/task-store/serialization.ts b/packages/core/src/task-store/serialization.ts index 54d4515f9f..afdc350787 100644 --- a/packages/core/src/task-store/serialization.ts +++ b/packages/core/src/task-store/serialization.ts @@ -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(row.tokenUsagePerModel) ?? undefined, diff --git a/packages/dashboard/app/components/__tests__/TaskCard.cost-badge.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.cost-badge.test.tsx new file mode 100644 index 0000000000..50d8f7040c --- /dev/null +++ b/packages/dashboard/app/components/__tests__/TaskCard.cost-badge.test.tsx @@ -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 { + 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( + + + , + ); + + 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(); + expect(disabled.container.querySelector(".card-cost-indicator")).toBeNull(); + disabled.unmount(); + + const missing = render( + + + , + ); + expect(missing.container.querySelector(".card-cost-indicator")).toBeNull(); + expect(missing.container.querySelector(".card-cost-indicator[aria-label]")).toBeNull(); + missing.unmount(); + + const zero = render( + + + , + ); + expect(zero.container.querySelector(".card-cost-indicator")).toBeNull(); + }); + + it("renders once alongside leading files-changed footer content", () => { + const { container } = render( + + + , + ); + + 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( + + + , + ); + + 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/); + }); +}); diff --git a/packages/dashboard/app/utils/__tests__/taskTokenCost.test.ts b/packages/dashboard/app/utils/__tests__/taskTokenCost.test.ts index f6f5535b84..b67a34e406 100644 --- a/packages/dashboard/app/utils/__tests__/taskTokenCost.test.ts +++ b/packages/dashboard/app/utils/__tests__/taskTokenCost.test.ts @@ -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);