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:
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user