Skip to content

Commit ab5d66e

Browse files
committed
fix: update goal budget and live cost tracking
1 parent 23440dd commit ab5d66e

16 files changed

Lines changed: 460 additions & 59 deletions

src/browser/features/RightSidebar/GoalTab.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export function GoalTab(props: GoalTabProps) {
9292
if (editingField === "budget") {
9393
const budgetCents = parseBudgetInput(submittedValue);
9494
if (budgetCents === undefined) {
95-
setError("Enter a budget like $5, 500c, or leave blank for no budget.");
95+
setError("Enter a budget like $5 or 500c. Use 0 or blank for no budget.");
9696
return;
9797
}
9898
await props.onUpdateBudget?.(budgetCents);
@@ -330,7 +330,7 @@ export function GoalTab(props: GoalTabProps) {
330330
/>
331331
<p className="text-muted mt-1 text-xs">
332332
{editingField === "budget"
333-
? "Use $5, 500c, or blank for no budget."
333+
? "Use $5, 500c, 0, or blank for no budget."
334334
: "Use a positive whole number, or blank for no cap."}
335335
</p>
336336
<div className="mt-2 flex gap-2">

src/browser/features/RightSidebar/RightSidebar.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig";
1919
import { useAPI } from "@/browser/contexts/API";
2020
import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions";
2121
import {
22+
hasGoalBudgetLimit,
2223
modelHasPricingData,
2324
UNPRICED_CURRENT_MODEL_GOAL_MESSAGE,
2425
} from "@/common/utils/goals/budgetPricing";
@@ -687,7 +688,10 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
687688
};
688689

689690
const handleGoalUpdateBudget = async (budgetCents: number | null) => {
690-
if (budgetCents != null && !modelHasPricingData(sendMessageOptions.model, providersConfig)) {
691+
if (
692+
hasGoalBudgetLimit(budgetCents) &&
693+
!modelHasPricingData(sendMessageOptions.model, providersConfig)
694+
) {
691695
throw new Error(UNPRICED_CURRENT_MODEL_GOAL_MESSAGE);
692696
}
693697
await setGoalWithSingleConflictRetry({ budgetCents });

src/browser/features/Settings/Sections/GoalsSection.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ export function GoalsSection() {
8989
<label htmlFor="goal-default-budget" className="min-w-0 flex-1">
9090
<div className="text-foreground text-sm font-medium">Default goal budget</div>
9191
<div className="text-muted mt-0.5 text-xs">
92-
Applied to new goals when no budget flag is provided.
92+
Applied to new goals when no budget flag is provided. Use $0.00 for no dollar limit.
9393
</div>
9494
</label>
9595
<div className="flex items-center gap-1">

src/browser/utils/chatCommands.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,6 +755,35 @@ describe("processSlashCommand - goal budgets", () => {
755755
});
756756
});
757757

758+
test("passes zero-dollar budget updates through on unpriced current model", async () => {
759+
enableGoalsExperiment();
760+
const currentGoal = {
761+
goalId: "11111111-1111-4111-8111-111111111111",
762+
objective: "existing objective",
763+
};
764+
const setGoal = mock().mockResolvedValueOnce({
765+
success: true,
766+
data: { ...currentGoal, budgetCents: null },
767+
});
768+
const context = createGoalCommandContext({
769+
config: { getConfig: mock(() => Promise.resolve({})) },
770+
workspace: {
771+
getGoal: mock(() => Promise.resolve({ goal: currentGoal })),
772+
setGoal,
773+
clearGoal: mock(),
774+
},
775+
} as unknown as SlashCommandContext["api"]);
776+
context.sendMessageOptions.model = "custom-provider:no-price-model";
777+
778+
await processSlashCommand({ type: "goal-budget", budgetCents: 0 }, context);
779+
780+
expect(setGoal).toHaveBeenCalledWith({
781+
workspaceId: "goal-ws",
782+
budgetCents: 0,
783+
expectedGoalId: currentGoal.goalId,
784+
});
785+
});
786+
758787
test("refuses budgeted goals on an unpriced current model", async () => {
759788
enableGoalsExperiment();
760789
const setGoal = mock();

src/browser/utils/chatCommands.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import type { ParsedCommand } from "@/browser/utils/slashCommands/types";
3939
import { type GoalDefaults } from "@/constants/goals";
4040
import {
4141
hasBudgetedResumableGoal,
42+
hasGoalBudgetLimit,
4243
modelHasPricingData,
4344
UNPRICED_CURRENT_MODEL_GOAL_MESSAGE,
4445
UNPRICED_TARGET_MODEL_GOAL_MESSAGE,
@@ -824,7 +825,7 @@ async function handleGoalCommand(
824825
}
825826

826827
if (parsed.type === "goal-budget") {
827-
if (parsed.budgetCents != null && !(await currentModelHasPricingData(context))) {
828+
if (hasGoalBudgetLimit(parsed.budgetCents) && !(await currentModelHasPricingData(context))) {
828829
showUnpricedModelGoalToast(setToast);
829830
return { clearInput: false, toastShown: true };
830831
}
@@ -844,7 +845,10 @@ async function handleGoalCommand(
844845

845846
const goalDefaults = await getGoalDefaults(context);
846847
const goalSetIntent = resolveSlashGoalSetIntent(parsed, goalDefaults);
847-
if (goalSetIntent.budgetCents != null && !(await currentModelHasPricingData(context))) {
848+
if (
849+
hasGoalBudgetLimit(goalSetIntent.budgetCents) &&
850+
!(await currentModelHasPricingData(context))
851+
) {
848852
showUnpricedModelGoalToast(setToast);
849853
return { clearInput: false, toastShown: true };
850854
}

src/browser/utils/commands/sources.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,34 @@ test("goal set objective prompt treats blank budget as explicit no-budget", asyn
519519
});
520520
});
521521

522+
test("goal set objective prompt allows zero budget on unpriced model", async () => {
523+
const setGoal = mock(() => Promise.resolve({ success: true, data: makeGoalRecord("active") }));
524+
const actions = getVisibleGoalActions({
525+
api: {
526+
config: { getConfig: mock(() => Promise.resolve({})) },
527+
providers: { getConfig: mock(() => Promise.resolve({})) },
528+
workspace: { getGoal: mock(() => Promise.resolve({ goal: null })), setGoal },
529+
} as unknown as APIClient,
530+
selectedWorkspaceState: {
531+
...makeWorkspaceState(null),
532+
currentModel: "custom:unpriced-model",
533+
},
534+
});
535+
536+
const setObjectiveAction = actions.find((action) => action.id === "goal:set-objective");
537+
await setObjectiveAction!.prompt!.onSubmit({
538+
objective: "Track without dollar limit",
539+
budget: "0",
540+
});
541+
542+
expect(setGoal).toHaveBeenCalledWith(
543+
expect.objectContaining({
544+
objective: "Track without dollar limit",
545+
budgetCents: null,
546+
})
547+
);
548+
});
549+
522550
test("goal set objective prompt submits objective and parsed budget", async () => {
523551
const getGoal = mock(() => Promise.resolve({ goal: null }));
524552
const setGoal = mock(() => Promise.resolve({ success: true, data: makeGoalRecord("active") }));

src/browser/utils/commands/sources.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { parseGoalBudgetCents } from "@/browser/utils/slashCommands/registry";
4949
import { setGoalWithConflictRetry } from "@/browser/utils/goals/setGoalWithConflictRetry";
5050
import { loadGoalDefaults, resolveGoalSetIntent } from "@/browser/utils/goals/resolveGoalSetIntent";
5151
import {
52+
hasGoalBudgetLimit,
5253
modelHasPricingData,
5354
UNPRICED_CURRENT_MODEL_GOAL_MESSAGE,
5455
} from "@/common/utils/goals/budgetPricing";
@@ -301,7 +302,7 @@ function canSetBudgetedGoalWithCurrentPaletteModel(
301302
budgetCents: number | null,
302303
providersConfig: unknown
303304
): boolean {
304-
if (budgetCents == null) {
305+
if (!hasGoalBudgetLimit(budgetCents)) {
305306
return true;
306307
}
307308
const selectedModel =

src/browser/utils/goals/resolveGoalSetIntent.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ describe("resolveGoalSetIntent", () => {
1515
expect(intent.budgetCents).toBe(200);
1616
});
1717

18+
test("treats explicit zero budget as no budget", () => {
19+
const intent = resolveGoalSetIntent({ objective: "ship", budgetCents: 0 }, baseDefaults);
20+
expect(intent.budgetCents).toBeNull();
21+
});
22+
1823
test("preserves explicit null budget (user-cleared)", () => {
1924
const intent = resolveGoalSetIntent({ objective: "ship", budgetCents: null }, baseDefaults);
2025
expect(intent.budgetCents).toBeNull();
@@ -36,6 +41,14 @@ describe("resolveGoalSetIntent", () => {
3641
expect(intent.budgetCents).toBe(1500);
3742
});
3843

44+
test("treats a zero default budget as no budget", () => {
45+
const intent = resolveGoalSetIntent(
46+
{ objective: "ship" },
47+
{ ...baseDefaults, defaultBudgetCents: 0, alwaysRequireExplicitBudget: true }
48+
);
49+
expect(intent.budgetCents).toBeNull();
50+
});
51+
3952
test("turnCap falls back to defaultTurnCap when omitted", () => {
4053
const intent = resolveGoalSetIntent({ objective: "ship" }, baseDefaults);
4154
expect(intent.turnCap).toBe(7);

src/browser/utils/goals/resolveGoalSetIntent.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { normalizeGoalBudgetCents } from "@/common/utils/goals/budgetPricing";
12
import type { APIClient } from "@/browser/contexts/API";
23
import { DEFAULT_GOAL_DEFAULTS, normalizeGoalDefaults, type GoalDefaults } from "@/constants/goals";
34

@@ -7,8 +8,8 @@ import { DEFAULT_GOAL_DEFAULTS, normalizeGoalDefaults, type GoalDefaults } from
78
*
89
* - `budgetCents` is a discriminated tri-state:
910
* - `undefined` → "user did not specify; apply default"
10-
* - `null` → "user explicitly cleared the budget"
11-
* - `number` → explicit cents value
11+
* - `null` or `0` → "no budget" (explicit clear)
12+
* - positive `number` → explicit cents value
1213
*/
1314
export interface GoalSetIntentInput {
1415
objective: string;
@@ -29,7 +30,7 @@ export interface GoalSetIntent {
2930
* - If the caller omitted `budgetCents`:
3031
* - `alwaysRequireExplicitBudget` → fall back to `defaultBudgetCents`.
3132
* - Otherwise → `null` (no budget).
32-
* - `null` is preserved (explicit "no budget" clear).
33+
* - `null` and `0` both become no budget (explicit "no budget" clear).
3334
* - If the caller omitted `turnCap`, fall back to `defaultTurnCap`.
3435
*
3536
* Coder-agents-review P3 DEREM-27: the slash command path (`/goal`) used to
@@ -44,9 +45,9 @@ export function resolveGoalSetIntent(
4445
): GoalSetIntent {
4546
let budgetCents: number | null;
4647
if (input.budgetCents !== undefined) {
47-
budgetCents = input.budgetCents;
48+
budgetCents = normalizeGoalBudgetCents(input.budgetCents);
4849
} else if (defaults.alwaysRequireExplicitBudget) {
49-
budgetCents = defaults.defaultBudgetCents;
50+
budgetCents = normalizeGoalBudgetCents(defaults.defaultBudgetCents);
5051
} else {
5152
budgetCents = null;
5253
}

src/common/utils/goals/budgetParser.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ describe("parseGoalBudgetInputCents", () => {
2929
expect(parseGoalBudgetInputCents("100C")).toBe(100);
3030
});
3131

32+
test("parses zero dollar inputs", () => {
33+
expect(parseGoalBudgetInputCents("0")).toBe(0);
34+
expect(parseGoalBudgetInputCents("$0")).toBe(0);
35+
expect(parseGoalBudgetInputCents("$0.00")).toBe(0);
36+
expect(parseGoalBudgetInputCents("0c")).toBe(0);
37+
});
38+
3239
test("returns undefined for invalid input", () => {
3340
expect(parseGoalBudgetInputCents("abc")).toBeUndefined();
3441
expect(parseGoalBudgetInputCents("$")).toBeUndefined();

0 commit comments

Comments
 (0)