Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- A job's panel shows only its model and cost. The token counts are gone: input counted the cached conversation again on every response, so it grew far past what was sent. A provider that reports no cost, such as Claude Code, shows only the model instead of `$0.0000`. The subtitle no longer repeats "Coding job" or "Desktop job". The API's `usage` field still has the token counts.
- A delegation no longer reports done when the receiving bot only started a job. It stays pending until the coding or desktop job has reported and the receiving bot has answered the report, then returns that answer too; a failed or stopped job fails the delegation. A coding job's question about delegated work goes first to the bot that delegated it, which answers with the new `answer_job_question` tool or tells you when only you can decide. The question still shows in the receiving bot's chat and job panel, and the first answer, the bot's or yours, wins.
- A delegated message shows once in the receiving bot's chat, as the message from the sending bot. The "Bot communication" card that repeated it there is gone; the sending bot's chat and the Activity page keep their cards.
- Claude Code's provider row is one line like the others. Its menu (**⋯**) copies the sign-in command, or the sign-out command when signed in, instead of showing the command under the row; a note still shows when `claude` is missing or its status can't be read. Row menus across the web UI now fit their items instead of wrapping them.
Expand Down
21 changes: 21 additions & 0 deletions apps/web/features/jobs/jobs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { expect, test } from "bun:test";
import { costLine } from "./jobs";

const priced = { cost: { total: 1.5 } };

test("costLine sums every model", () => {
expect(costLine({ models: { "a/one": priced, "b/two": priced } })).toBe(
"$3.0000",
);
});

test("costLine is empty for a provider that reports no cost", () => {
expect(
costLine({ models: { "claude-code/one": { cost: { total: 0 } } } }),
).toBeNull();
});

test("costLine is empty before the first request", () => {
expect(costLine(undefined)).toBeNull();
expect(costLine({ models: {} })).toBeNull();
});
39 changes: 7 additions & 32 deletions apps/web/features/jobs/jobs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,41 +50,20 @@ type Block = {
arguments?: { command?: string; path?: string };
};

type Totals = { input: number; output: number; cost: number };

type UsageModels = {
models?: Record<
string,
{
input?: number;
output?: number;
cacheRead?: number;
cost?: { total?: number };
}
>;
models?: Record<string, { cost?: { total?: number } }>;
};

const numberFormat = new Intl.NumberFormat();

/** Sums a job's token use and cost over its models, as `{in} in · {out} out · $cost`. */
export function usageLine(usage: CodingJob["usage"]) {
// SAFETY: Pi Durable usage documents keep per-model token counts and cost under `models`.
/** A job's cost over its models. Subscription providers such as Claude Code report zero, which says nothing. */
export function costLine(usage: CodingJob["usage"]) {
// SAFETY: Pi Durable usage documents keep per-model cost under `models`.
const models = Object.values(
(usage as UsageModels | undefined)?.models ?? {},
);

if (models.length === 0) return null;

const totals = models.reduce<Totals>(
(sum, model) => ({
input: sum.input + (model.input ?? 0) + (model.cacheRead ?? 0),
output: sum.output + (model.output ?? 0),
cost: sum.cost + (model.cost?.total ?? 0),
}),
{ input: 0, output: 0, cost: 0 },
);
const cost = models.reduce((sum, model) => sum + (model.cost?.total ?? 0), 0);

return `${numberFormat.format(totals.input)} in · ${numberFormat.format(totals.output)} out · $${totals.cost.toFixed(4)}`;
return cost > 0 ? `$${cost.toFixed(4)}` : null;
}

const STEPS = new Map([
Expand Down Expand Up @@ -455,11 +434,7 @@ function JobTitle({
}) {
const Icon = job.kind === "coding" ? IconHammer : IconDeviceDesktop;

const subtitle = [
kindLabel(job),
current?.model?.model,
usageLine(current?.usage),
];
const subtitle = [current?.model?.model, costLine(current?.usage)];

return (
<>
Expand Down
Loading