fix(cli): allowed goal set to replace active goals

Allowed /goal set to replace the current active goal instead of rejecting and discarding the command input.

Added goal runtime and interactive-mode regression coverage for active replacements.

Fixes #1293
This commit is contained in:
roboomp
2026-05-22 13:23:06 +00:00
parent e6c08c1155
commit ccba77c5c7
4 changed files with 96 additions and 18 deletions
+35 -13
View File
@@ -373,6 +373,21 @@ export class GoalRuntime {
await this.#withAccounting(() => this.#flushUsageLocked(steering, currentUsage));
}
#createGoalState(objective: string, tokenBudget: number | undefined): GoalModeState {
const now = this.#now();
const goal: Goal = {
id: String(Snowflake.next()),
objective,
status: "active",
tokenBudget,
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: now,
updatedAt: now,
};
return { enabled: true, mode: "active", goal };
}
async createGoal(input: { objective: string; tokenBudget?: number }): Promise<GoalModeState> {
const objective = input.objective.trim();
if (!objective) throw new Error("objective is required when op=create");
@@ -382,20 +397,27 @@ export class GoalRuntime {
if (existing?.goal && existing.goal.status !== "dropped" && existing.goal.status !== "complete") {
throw new Error("cannot create a new goal because this session already has a goal");
}
const now = this.#now();
const goal: Goal = {
id: String(Snowflake.next()),
objective,
status: "active",
tokenBudget: input.tokenBudget,
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: now,
updatedAt: now,
};
const state: GoalModeState = { enabled: true, mode: "active", goal };
const state = this.#createGoalState(objective, input.tokenBudget);
this.#budgetReportedFor = undefined;
this.#markActiveAccounting(goal);
this.#markActiveAccounting(state.goal);
await this.#commitState(state, { persist: "goal" });
return state;
});
}
async replaceGoal(input: { objective: string; tokenBudget?: number }): Promise<GoalModeState> {
const objective = input.objective.trim();
if (!objective) throw new Error("objective is required when op=replace");
validateTokenBudget(input.tokenBudget);
return await this.#withAccounting(async () => {
const existing = this.#host.getState();
if (!existing?.enabled || !isAccountingStatus(existing.goal)) {
throw new Error("cannot replace goal because no goal is active");
}
await this.#flushUsageLocked("suppressed");
const state = this.#createGoalState(objective, input.tokenBudget);
this.#budgetReportedFor = undefined;
this.#markActiveAccounting(state.goal);
await this.#commitState(state, { persist: "goal" });
return state;
});
@@ -1876,12 +1876,23 @@ export class InteractiveMode implements InteractiveModeContext {
}
}
async #handleGoalSetSubcommand(rest: string): Promise<void> {
if (this.goalModeEnabled) {
this.showStatus("Goal mode is already active. Use /goal drop to start over.");
return;
async #replaceGoalFromObjective(objective: string): Promise<void> {
const state = await this.session.goalRuntime.replaceGoal({ objective });
this.session.setGoalModeState(state);
this.goalModeEnabled = true;
this.goalModePaused = false;
this.#resetGoalContinuationSuppression();
this.#updateGoalModeStatus();
if (this.session.isStreaming) {
await this.session.sendGoalModeContext({ deliverAs: "steer" });
}
if (this.#getPausedGoalState()) {
if (this.onInputCallback) {
this.onInputCallback(this.startPendingSubmission({ text: objective }));
}
}
async #handleGoalSetSubcommand(rest: string): Promise<void> {
if (!this.goalModeEnabled && this.#getPausedGoalState()) {
this.showWarning("Resume the current goal first, or drop it before setting a new objective.");
return;
}
@@ -1889,6 +1900,10 @@ export class InteractiveMode implements InteractiveModeContext {
? rest.trim()
: (await this.showHookEditor("Goal objective", undefined, undefined, { promptStyle: true }))?.trim();
if (!objective) return;
if (this.goalModeEnabled) {
await this.#replaceGoalFromObjective(objective);
return;
}
await this.#startGoalFromObjective(objective);
}
@@ -130,6 +130,22 @@ describe("InteractiveMode goal mode integration", () => {
expect(await toolNamesFor(harness)).not.toContain("goal");
});
it("replaces the active goal via /goal set", async () => {
await harness.mode.handleGoalModeCommand("Ship the release");
const originalGoal = harness.session.getGoalModeState()?.goal;
if (!originalGoal) throw new Error("expected active goal");
await harness.mode.handleGoalModeCommand("set Replace the objective");
const state = harness.session.getGoalModeState();
expect(state?.enabled).toBe(true);
expect(state?.goal.objective).toBe("Replace the objective");
expect(state?.goal.status).toBe("active");
expect(state?.goal.id).not.toBe(originalGoal.id);
expect(harness.mode.goalModeEnabled).toBe(true);
expect(await toolNamesFor(harness)).toContain("goal");
});
it("refuses /goal while plan mode is active", async () => {
const showWarning = vi.spyOn(harness.mode, "showWarning");
harness.mode.planModeEnabled = true;
@@ -297,6 +297,31 @@ describe("goal runtime", () => {
);
});
it("replaces an active goal with a fresh active goal", async () => {
const harness = createHarness({
state: {
enabled: true,
mode: "active",
goal: createGoal({ objective: "Existing", tokenBudget: 100 }),
},
});
harness.runtime.onTurnStart("turn-1", createUsage());
harness.advance(1_000);
harness.setUsage({ input: 12 });
const next = await harness.runtime.replaceGoal({ objective: "Second", tokenBudget: 25 });
expect(next.enabled).toBe(true);
expect(next.goal.objective).toBe("Second");
expect(next.goal.status).toBe("active");
expect(next.goal.tokenBudget).toBe(25);
expect(next.goal.tokensUsed).toBe(0);
expect(next.goal.timeUsedSeconds).toBe(0);
expect(next.goal.id).not.toBe("goal-1");
expect(harness.persists.at(-1)?.state?.goal.objective).toBe("Second");
});
it("allows creating a new goal after the previous one is complete", async () => {
const harness = createHarness({
state: {