coder: add persistent task goals (/goal) across session and runtime (#55)

c4d189d4bcaa · AtlantisPleb · · parent c42732617176

coder: add persistent task goals (/goal) across session and runtime (#55)

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified packages/openagents-cli/src/cli.ts
  • added packages/openagents-cli/src/coder-goals.ts
  • modified packages/openagents-cli/src/coder-session.ts
  • modified packages/openagents-cli/src/coder-ui.ts
  • added packages/openagents-cli/test/coder-goals.test.ts
  • modified packages/openagents-cli/test/coder-session.test.ts

Diff

6 files changed, +522 -0

packages/openagents-cli/src/cli.ts modified +4

@@ -86,6 +86,7 @@ import { toolFamilyOf } from "./coder-tool-families.js";

86 86
import { openLocalThread, threadAnnouncement, threadSyncWanted } from "./coder-local-thread.js";
87 87
import { ThreadTranscriptWriter } from "./coder-transcript.js";
88 88
import { delegateTool, openagentsTool, shellTool, skillTool } from "./coder-tools.js";
89
import { InMemoryGoalStore, goalTool } from "./coder-goals.js";
89 90
import {
90 91
  describeLoad,
91 92
  invokePlugin,

@@ -2448,6 +2449,7 @@ const coderCommand = Command.make(

2448 2449
            }
2449 2450
          : undefined;
2450 2451
2452
      const goalStore = new InMemoryGoalStore();
2451 2453
      const session = new CoderSession(
2452 2454
        source,
2453 2455
        workspace.repository,

@@ -2460,6 +2462,7 @@ const coderCommand = Command.make(

2460 2462
            ? { initial: initialTier, build: buildTier }
2461 2463
            : undefined),
2462 2464
        (prompt) => capabilityRetrieval(prompt),
2465
        goalStore,
2463 2466
      );
2464 2467
2465 2468
      // The resumed thread's history goes on the session before anything new,

@@ -2578,6 +2581,7 @@ const coderCommand = Command.make(

2578 2581
          shellTool(process.cwd()),
2579 2582
          ...(active.length === 0 ? [] : [skillTool(active)]),
2580 2583
          openagentsTool(),
2584
          goalTool(goalStore),
2581 2585
          ...(setup === undefined ? [] : [delegateTool(setup.delegation)]),
2582 2586
          capability,
2583 2587
          ...visiblePlugins().map((plugin) => {
packages/openagents-cli/src/coder-goals.ts added +282

@@ -0,0 +1,282 @@

1
/**
2
 * Persistent task goals for `openagents coder`.
3
 *
4
 * Inspired by Codex's goal extension architecture:
5
 * - Persists an active high-level task goal across turns.
6
 * - Supports `/goal <objective>`, `/goal status`, `/goal clear`, `/goal pause`, `/goal resume`.
7
 * - Easter egg: `/goooooal` with arbitrary repeated `o`s resolves to `/goal`.
8
 * - Tracks status, token budget, tokens used, elapsed time, and turn progression.
9
 * - Exposes `goal` tool to the agent model (`get`, `update`, `complete`, `block`).
10
 * - Generates continuation prompts and budget exhaustion prompts for multi-turn alignment.
11
 */
12
13
import type { CoderTool } from "./coder-tools.js";
14
15
export type GoalStatus =
16
  | "active"
17
  | "paused"
18
  | "completed"
19
  | "abandoned"
20
  | "budget_limited"
21
  | "blocked";
22
23
export interface PersistentGoal {
24
  readonly id: string;
25
  readonly objective: string;
26
  status: GoalStatus;
27
  readonly tokenBudget?: number;
28
  tokensUsed: number;
29
  timeUsedSeconds: number;
30
  readonly createdAt: number;
31
  updatedAt: number;
32
}
33
34
export interface GoalStore {
35
  getGoal(): PersistentGoal | undefined;
36
  setGoal(objective: string, tokenBudget?: number): PersistentGoal;
37
  updateStatus(status: GoalStatus): PersistentGoal | undefined;
38
  clearGoal(): boolean;
39
  addUsage(tokens: number, elapsedSeconds: number): void;
40
}
41
42
/** In-memory goal manager for a CoderSession */
43
export class InMemoryGoalStore implements GoalStore {
44
  private currentGoal: PersistentGoal | undefined;
45
46
  getGoal(): PersistentGoal | undefined {
47
    return this.currentGoal;
48
  }
49
50
  setGoal(objective: string, tokenBudget?: number): PersistentGoal {
51
    const now = Date.now();
52
    const goal: PersistentGoal = {
53
      id: `goal_${Math.random().toString(36).slice(2, 10)}`,
54
      objective: objective.trim(),
55
      status: "active",
56
      ...(tokenBudget !== undefined && tokenBudget > 0 ? { tokenBudget } : {}),
57
      tokensUsed: 0,
58
      timeUsedSeconds: 0,
59
      createdAt: now,
60
      updatedAt: now,
61
    };
62
    this.currentGoal = goal;
63
    return goal;
64
  }
65
66
  updateStatus(status: GoalStatus): PersistentGoal | undefined {
67
    if (this.currentGoal === undefined) return undefined;
68
    this.currentGoal.status = status;
69
    this.currentGoal.updatedAt = Date.now();
70
    return this.currentGoal;
71
  }
72
73
  clearGoal(): boolean {
74
    if (this.currentGoal === undefined) return false;
75
    this.currentGoal = undefined;
76
    return true;
77
  }
78
79
  addUsage(tokens: number, elapsedSeconds: number): void {
80
    if (this.currentGoal === undefined || this.currentGoal.status !== "active") return;
81
    this.currentGoal.tokensUsed += tokens;
82
    this.currentGoal.timeUsedSeconds += Math.max(0, Math.round(elapsedSeconds));
83
    this.currentGoal.updatedAt = Date.now();
84
85
    if (
86
      this.currentGoal.tokenBudget !== undefined &&
87
      this.currentGoal.tokensUsed >= this.currentGoal.tokenBudget
88
    ) {
89
      this.currentGoal.status = "budget_limited";
90
    }
91
  }
92
}
93
94
/** Check if a prompt matches `/goal` or repeated `/goooooal` */
95
export function isGoalSlashCommand(input: string): boolean {
96
  const trimmed = input.trim();
97
  if (!trimmed.startsWith("/")) return false;
98
  return /^\/g(o+)al(\s+.*)?$/is.test(trimmed);
99
}
100
101
export interface GoalCommandParseResult {
102
  kind: "set" | "clear" | "pause" | "resume" | "status";
103
  objective?: string;
104
  tokenBudget?: number;
105
}
106
107
/** Parse `/goal ...` arguments into a structured action */
108
export function parseGoalSlashCommand(input: string): GoalCommandParseResult | undefined {
109
  const trimmed = input.trim();
110
  const match = /^\/g(?:o+)al(?:\s+(.*))?$/is.exec(trimmed);
111
  if (!match) return undefined;
112
113
  const rawArgs = match[1]?.trim() ?? "";
114
  if (rawArgs.length === 0 || rawArgs.toLowerCase() === "status") {
115
    return { kind: "status" };
116
  }
117
  if (rawArgs.toLowerCase() === "clear") {
118
    return { kind: "clear" };
119
  }
120
  if (rawArgs.toLowerCase() === "pause") {
121
    return { kind: "pause" };
122
  }
123
  if (rawArgs.toLowerCase() === "resume") {
124
    return { kind: "resume" };
125
  }
126
127
  // Parse optional token budget prefix e.g. "--budget 50000 <objective>"
128
  let objective = rawArgs;
129
  let tokenBudget: number | undefined;
130
131
  const budgetFlagMatch = /^--budget\s+(\d+)\s+(.+)$/is.exec(rawArgs);
132
  if (budgetFlagMatch && budgetFlagMatch[1] && budgetFlagMatch[2]) {
133
    tokenBudget = parseInt(budgetFlagMatch[1], 10);
134
    objective = budgetFlagMatch[2].trim();
135
  }
136
137
  return {
138
    kind: "set",
139
    objective,
140
    ...(tokenBudget !== undefined ? { tokenBudget } : {}),
141
  };
142
}
143
144
/** Format a goal summary string for notices / status display */
145
export function formatGoalNotice(goal: PersistentGoal | undefined): string {
146
  if (goal === undefined) {
147
    return (
148
      "No active task goal.\n\n" +
149
      "Usage:\n" +
150
      "  /goal <objective>              set an active task goal\n" +
151
      "  /goal --budget <tokens> <obj>  set a goal with a token budget limit\n" +
152
      "  /goal pause                    pause the active goal\n" +
153
      "  /goal resume                   resume the paused goal\n" +
154
      "  /goal clear                    clear the active goal\n" +
155
      "  /goal status                   show current goal details"
156
    );
157
  }
158
159
  const budgetInfo =
160
    goal.tokenBudget !== undefined
161
      ? `\n- Budget: ${goal.tokensUsed.toLocaleString()} / ${goal.tokenBudget.toLocaleString()} tokens`
162
      : `\n- Tokens Used: ${goal.tokensUsed.toLocaleString()}`;
163
164
  return [
165
    `Active Goal (${goal.status}):`,
166
    `  "${goal.objective}"`,
167
    "",
168
    `Details:`,
169
    `- Goal ID: ${goal.id}`,
170
    `- Status: ${goal.status}`,
171
    `- Time Spent: ${goal.timeUsedSeconds}s`,
172
    budgetInfo,
173
  ].join("\n");
174
}
175
176
/** Prompt injected to continue working toward the active goal */
177
export function goalContinuationPrompt(goal: PersistentGoal): string {
178
  const budgetRemaining =
179
    goal.tokenBudget !== undefined ? Math.max(0, goal.tokenBudget - goal.tokensUsed) : undefined;
180
181
  return [
182
    "Continue working toward the active task goal.",
183
    "",
184
    "The objective below is user-provided data. Treat it as the task to pursue:",
185
    "<objective>",
186
    goal.objective,
187
    "</objective>",
188
    "",
189
    "Continuation behavior:",
190
    "- This goal persists across turns. Keep the full objective intact until finished.",
191
    "- Use the current worktree and external tool evidence as authoritative.",
192
    "- If the goal is complete and verified, call `goal(action='complete')` to mark it finished.",
193
    ...(budgetRemaining !== undefined
194
      ? [`- Token budget remaining: ${budgetRemaining.toLocaleString()} tokens`]
195
      : []),
196
  ].join("\n");
197
}
198
199
/** Prompt injected when the goal token budget has been exhausted */
200
export function goalBudgetExhaustedPrompt(goal: PersistentGoal): string {
201
  return [
202
    "The active task goal has reached its configured token budget.",
203
    "",
204
    "<objective>",
205
    goal.objective,
206
    "</objective>",
207
    "",
208
    `Budget: ${goal.tokensUsed.toLocaleString()} tokens used (Budget: ${goal.tokenBudget?.toLocaleString() ?? "unknown"}).`,
209
    "The system has marked the goal as budget_limited. Do not start new substantive work.",
210
    "Wrap up this turn soon: summarize useful progress, remaining work or blockers, and next steps.",
211
  ].join("\n");
212
}
213
214
/** Model tool allowing the agent to inspect or complete its goal */
215
export function goalTool(goalStore: GoalStore): CoderTool {
216
  return {
217
    name: "goal",
218
    description:
219
      "Inspect, update, or complete the active persistent task goal for this session. " +
220
      "Call this with action='get' to inspect current goal status & budget, or action='complete'/'block' to update status.",
221
    parameters: {
222
      type: "object",
223
      properties: {
224
        action: {
225
          type: "string",
226
          enum: ["get", "complete", "block", "pause", "resume"],
227
          description: "The goal operation to perform.",
228
        },
229
        notes: {
230
          type: "string",
231
          description: "Optional notes or outcome summary.",
232
        },
233
      },
234
      required: ["action"],
235
    },
236
    async run(args: Record<string, unknown>): Promise<string> {
237
      const action = String(args.action || "get");
238
      const current = goalStore.getGoal();
239
240
      if (action === "get") {
241
        if (current === undefined) {
242
          return JSON.stringify({ active: false, message: "No active goal set." });
243
        }
244
        return JSON.stringify({
245
          active: true,
246
          id: current.id,
247
          objective: current.objective,
248
          status: current.status,
249
          tokensUsed: current.tokensUsed,
250
          tokenBudget: current.tokenBudget,
251
          timeUsedSeconds: current.timeUsedSeconds,
252
        });
253
      }
254
255
      if (current === undefined) {
256
        return "Refusal: No active goal to update.";
257
      }
258
259
      if (action === "complete") {
260
        goalStore.updateStatus("completed");
261
        return `Goal ${current.id} marked as completed.`;
262
      }
263
264
      if (action === "block") {
265
        goalStore.updateStatus("blocked");
266
        return `Goal ${current.id} marked as blocked.`;
267
      }
268
269
      if (action === "pause") {
270
        goalStore.updateStatus("paused");
271
        return `Goal ${current.id} marked as paused.`;
272
      }
273
274
      if (action === "resume") {
275
        goalStore.updateStatus("active");
276
        return `Goal ${current.id} marked as active.`;
277
      }
278
279
      return `Unknown action: ${action}`;
280
    },
281
  };
282
}
packages/openagents-cli/src/coder-session.ts modified +53

@@ -26,6 +26,13 @@ import { exportTrajectory } from "./coder-export.js";

26 26
import { VERSION } from "./version.js";
27 27
import type { CoderTask, CoderTaskId, CoderTaskRegistry } from "./coder-tasks.js";
28 28
import type { CoderTool } from "./coder-tools.js";
29
import {
30
  formatGoalNotice,
31
  isGoalSlashCommand,
32
  parseGoalSlashCommand,
33
  type GoalStore,
34
  type PersistentGoal,
35
} from "./coder-goals.js";
29 36
30 37
/** What a reply source produces. One entry kind per member. */
31 38
export type ReplyChunk =

@@ -237,6 +244,8 @@ export interface CoderSnapshot {

237 244
   * then no renderer draws a fleet at all.
238 245
   */
239 246
  readonly tasks: ReadonlyArray<CoderTask>;
247
  /** The active persistent goal for this session, if one is set. */
248
  readonly goal?: PersistentGoal | undefined;
240 249
  /**
241 250
   * Plugin loads and refusals, oldest first.
242 251
   *

@@ -516,6 +525,7 @@ export class CoderSession {

516 525
   * stamped with its provenance the moment the call arrives.
517 526
   */
518 527
  private readonly pluginTools = new Map<string, CoderPluginProvenance>();
528
  private readonly goalStore: GoalStore | undefined;
519 529
520 530
  constructor(
521 531
    private source: ReplySource,

@@ -561,6 +571,7 @@ export class CoderSession {

561 571
     * the turn nothing. The model is never expected to consult a registry.
562 572
     */
563 573
    private readonly retrieve?: (prompt: string) => Promise<string | undefined>,
574
    goalStore?: GoalStore | undefined,
564 575
  ) {
565 576
    this.tier = tiers?.initial;
566 577
    // A child reporting progress has to reach the renderer, and the renderer

@@ -571,6 +582,7 @@ export class CoderSession {

571 582
572 583
    // Handed over before the first turn, so a source that composes a system
573 584
    // message has the context when it composes one.
585
    this.goalStore = goalStore;
574 586
    if (standing !== undefined && standing.length > 0) this.source.useContext?.(standing);
575 587
  }
576 588

@@ -608,6 +620,7 @@ export class CoderSession {

608 620
      turns: this.turnCount,
609 621
      budget: this.source.budget,
610 622
      tasks: this.delegation?.registry.list() ?? [],
623
      goal: this.goalStore?.getGoal(),
611 624
      pluginEvents: this.pluginEvents.map((event) => ({
612 625
        ...event,
613 626
        plugin: { ...event.plugin },

@@ -858,6 +871,39 @@ export class CoderSession {

858 871
      return;
859 872
    }
860 873
874
    // `/goal` manages persistent task goals across turns.
875
    if (isGoalSlashCommand(prompt)) {
876
      this.entries.push({ role: "you", text: prompt, settled: true, at: Date.now() });
877
      const parsed = parseGoalSlashCommand(prompt);
878
      if (this.goalStore === undefined) {
879
        this.notice("This session does not have goal storage configured.");
880
        this.emit();
881
        return;
882
      }
883
884
      if (!parsed || parsed.kind === "status") {
885
        this.notice(formatGoalNotice(this.goalStore.getGoal()));
886
      } else if (parsed.kind === "clear") {
887
        const cleared = this.goalStore.clearGoal();
888
        this.notice(cleared ? "Cleared active task goal." : "No active task goal to clear.");
889
      } else if (parsed.kind === "pause") {
890
        const updated = this.goalStore.updateStatus("paused");
891
        this.notice(updated ? `Paused task goal: "${updated.objective}"` : "No active task goal to pause.");
892
      } else if (parsed.kind === "resume") {
893
        const updated = this.goalStore.updateStatus("active");
894
        this.notice(updated ? `Resumed task goal: "${updated.objective}"` : "No task goal to resume.");
895
      } else if (parsed.kind === "set" && parsed.objective) {
896
        const goal = this.goalStore.setGoal(parsed.objective, parsed.tokenBudget);
897
        this.notice(
898
          `Set active goal: "${goal.objective}"` +
899
            (goal.tokenBudget !== undefined ? ` (Budget: ${goal.tokenBudget.toLocaleString()} tokens)` : "") +
900
            "\nCall /goal for details, or /goal clear to remove.",
901
        );
902
      }
903
      this.emit();
904
      return;
905
    }
906
861 907
    // `/export` is not a turn either: it writes what has already happened.
862 908
    if (/^\/export\s*$/.test(prompt.trim())) {
863 909
      this.entries.push({ role: "you", text: prompt, settled: true, at: Date.now() });

@@ -890,6 +936,7 @@ export class CoderSession {

890 936
        [
891 937
          "Commands",
892 938
          "  /help                       this list",
939
          "  /goal [<objective>]         set, inspect, or manage the task goal",
893 940
          "  /system                     what the model is told: tools, skills, and its",
894 941
          "                              standing context",
895 942
          "  /skills                     choose which skills the model is offered",

@@ -948,6 +995,7 @@ export class CoderSession {

948 995
        [
949 996
          "Commands:",
950 997
          "  /help     this list",
998
          "  /goal     set, inspect, or manage the task goal",
951 999
          "  /system   what the model is told, including tools and skills",
952 1000
          "  /skills   choose which skills the model is offered",
953 1001
          "  /resume   list or select a recent foreign coding session",

@@ -1043,6 +1091,7 @@ export class CoderSession {

1043 1091
1044 1092
    const controller = new AbortController();
1045 1093
    this.controller = controller;
1094
    const turnStart = Date.now();
1046 1095
    // Counted here rather than on completion. A turn that is happening is a
1047 1096
    // turn, and counting it only once it settled is what made the status line
1048 1097
    // read `0 replies` under a reply the reader was watching arrive.

@@ -1156,6 +1205,10 @@ export class CoderSession {

1156 1205
                : { cacheReadInputTokens: chunk.cacheReadInputTokens }),
1157 1206
            };
1158 1207
          }
1208
          if (this.goalStore !== undefined) {
1209
            const totalTokens = (chunk.promptTokens ?? 0) + (chunk.completionTokens ?? 0);
1210
            this.goalStore.addUsage(totalTokens, (Date.now() - turnStart) / 1000);
1211
          }
1159 1212
        } else {
1160 1213
          this.applyToolResult(chunk);
1161 1214
        }
packages/openagents-cli/src/coder-ui.ts modified +6

@@ -957,6 +957,12 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

957 957
      const facts = [snapshot.repository, snapshot.branch, snapshot.model];
958 958
      if (snapshot.reasoning !== undefined) facts.push(`thinking ${snapshot.reasoning}`);
959 959
      if (snapshot.budget !== undefined) facts.push(snapshot.budget);
960
      if (snapshot.goal !== undefined && snapshot.goal.status === "active") {
961
        const goalSnippet = snapshot.goal.objective.length > 25
962
          ? snapshot.goal.objective.slice(0, 22) + "…"
963
          : snapshot.goal.objective;
964
        facts.push(`goal: "${goalSnippet}"`);
965
      }
960 966
      let where = "";
961 967
      for (let from = 0; from < facts.length; from += 1) {
962 968
        const candidate = `${DIM}${facts.slice(from).join(" · ")}${RESET}`;
packages/openagents-cli/test/coder-goals.test.ts added +134

@@ -0,0 +1,134 @@

1
import { describe, expect, it } from "vitest";
2
import {
3
  formatGoalNotice,
4
  goalBudgetExhaustedPrompt,
5
  goalContinuationPrompt,
6
  goalTool,
7
  InMemoryGoalStore,
8
  isGoalSlashCommand,
9
  parseGoalSlashCommand,
10
} from "../src/coder-goals.js";
11
12
describe("isGoalSlashCommand", () => {
13
  it("matches standard /goal and variations", () => {
14
    expect(isGoalSlashCommand("/goal")).toBe(true);
15
    expect(isGoalSlashCommand("/goal foo bar")).toBe(true);
16
    expect(isGoalSlashCommand("/goooal build the thing")).toBe(true);
17
    expect(isGoalSlashCommand("/goooooooooal")).toBe(true);
18
    expect(isGoalSlashCommand("  /goal clear  ")).toBe(true);
19
  });
20
21
  it("does not match non-goal slash commands or regular text", () => {
22
    expect(isGoalSlashCommand("/help")).toBe(false);
23
    expect(isGoalSlashCommand("/reload")).toBe(false);
24
    expect(isGoalSlashCommand("what is our goal?")).toBe(false);
25
    expect(isGoalSlashCommand("goal")).toBe(false);
26
  });
27
});
28
29
describe("parseGoalSlashCommand", () => {
30
  it("parses status command", () => {
31
    expect(parseGoalSlashCommand("/goal")).toEqual({ kind: "status" });
32
    expect(parseGoalSlashCommand("/goal status")).toEqual({ kind: "status" });
33
    expect(parseGoalSlashCommand("/goooal")).toEqual({ kind: "status" });
34
  });
35
36
  it("parses control commands", () => {
37
    expect(parseGoalSlashCommand("/goal clear")).toEqual({ kind: "clear" });
38
    expect(parseGoalSlashCommand("/goal pause")).toEqual({ kind: "pause" });
39
    expect(parseGoalSlashCommand("/goal resume")).toEqual({ kind: "resume" });
40
  });
41
42
  it("parses set command with objective", () => {
43
    expect(parseGoalSlashCommand("/goal build persistent goals")).toEqual({
44
      kind: "set",
45
      objective: "build persistent goals",
46
    });
47
  });
48
49
  it("parses set command with --budget flag", () => {
50
    expect(parseGoalSlashCommand("/goal --budget 25000 refactor database schema")).toEqual({
51
      kind: "set",
52
      objective: "refactor database schema",
53
      tokenBudget: 25000,
54
    });
55
  });
56
});
57
58
describe("InMemoryGoalStore and goalTool", () => {
59
  it("manages goal lifecycle and usage tracking", async () => {
60
    const store = new InMemoryGoalStore();
61
    expect(store.getGoal()).toBeUndefined();
62
63
    const goal = store.setGoal("Write test suite", 10000);
64
    expect(goal.objective).toBe("Write test suite");
65
    expect(goal.status).toBe("active");
66
    expect(goal.tokenBudget).toBe(10000);
67
    expect(goal.tokensUsed).toBe(0);
68
69
    store.addUsage(2500, 10);
70
    expect(store.getGoal()?.tokensUsed).toBe(2500);
71
    expect(store.getGoal()?.timeUsedSeconds).toBe(10);
72
    expect(store.getGoal()?.status).toBe("active");
73
74
    // Exceeding budget marks goal budget_limited
75
    store.addUsage(8000, 15);
76
    expect(store.getGoal()?.tokensUsed).toBe(10500);
77
    expect(store.getGoal()?.status).toBe("budget_limited");
78
79
    // Tool interactions
80
    const tool = goalTool(store);
81
    const getRes = JSON.parse(await tool.run({ action: "get" }, new AbortController().signal));
82
    expect(getRes.active).toBe(true);
83
    expect(getRes.status).toBe("budget_limited");
84
85
    const completeRes = await tool.run({ action: "complete" }, new AbortController().signal);
86
    expect(completeRes).toContain("marked as completed");
87
    expect(store.getGoal()?.status).toBe("completed");
88
89
    // Clear
90
    expect(store.clearGoal()).toBe(true);
91
    expect(store.getGoal()).toBeUndefined();
92
  });
93
});
94
95
describe("formatGoalNotice and Prompts", () => {
96
  it("formats notice when no goal set", () => {
97
    const notice = formatGoalNotice(undefined);
98
    expect(notice).toContain("No active task goal.");
99
    expect(notice).toContain("Usage:");
100
  });
101
102
  it("formats notice with active goal and budget", () => {
103
    const store = new InMemoryGoalStore();
104
    const goal = store.setGoal("Implement feature", 50000);
105
    store.addUsage(1200, 5);
106
107
    const notice = formatGoalNotice(goal);
108
    expect(notice).toContain('Active Goal (active):');
109
    expect(notice).toContain('"Implement feature"');
110
    expect(notice).toContain("Budget: 1,200 / 50,000 tokens");
111
    expect(notice).toContain("Time Spent: 5s");
112
  });
113
114
  it("generates continuation prompt", () => {
115
    const store = new InMemoryGoalStore();
116
    const goal = store.setGoal("Implement feature", 50000);
117
    store.addUsage(10000, 20);
118
119
    const prompt = goalContinuationPrompt(goal);
120
    expect(prompt).toContain("Continue working toward the active task goal.");
121
    expect(prompt).toContain("<objective>\nImplement feature\n</objective>");
122
    expect(prompt).toContain("Token budget remaining: 40,000 tokens");
123
  });
124
125
  it("generates budget exhausted prompt", () => {
126
    const store = new InMemoryGoalStore();
127
    const goal = store.setGoal("Implement feature", 5000);
128
    store.addUsage(6000, 30);
129
130
    const prompt = goalBudgetExhaustedPrompt(goal);
131
    expect(prompt).toContain("reached its configured token budget");
132
    expect(prompt).toContain("budget_limited");
133
  });
134
});
packages/openagents-cli/test/coder-session.test.ts modified +43

@@ -2,6 +2,7 @@ import { mkdtempSync } from "node:fs";

2 2
import { tmpdir } from "node:os";
3 3
import { join } from "node:path";
4 4
import { describe, expect, it } from "vitest";
5
import { InMemoryGoalStore } from "../src/coder-goals.js";
5 6
6 7
import {
7 8
  CoderSession,

@@ -601,3 +602,45 @@ describe("plugin occurrences", () => {

601 602
    expect(tool?.tool?.plugin).toBeUndefined();
602 603
  });
603 604
});
605
606
describe("the /goal command in CoderSession", () => {
607
  it("handles /goal lifecycle via session prompts", async () => {
608
    const store = new InMemoryGoalStore();
609
    const session = new CoderSession(
610
      scripted(["hello"]),
611
      "repo",
612
      "main",
613
      undefined,
614
      undefined,
615
      undefined,
616
      undefined,
617
      undefined,
618
      store,
619
    );
620
621
    // Initial status notice
622
    await session.submit("/goal");
623
    const snap1 = session.snapshot();
624
    const notice1 = snap1.entries.find((e) => e.role === "notice");
625
    expect(notice1?.text).toContain("No active task goal.");
626
627
    // Set goal
628
    await session.submit("/goal Build persistent task goals");
629
    expect(store.getGoal()?.objective).toBe("Build persistent task goals");
630
    expect(store.getGoal()?.status).toBe("active");
631
    expect(session.snapshot().goal?.objective).toBe("Build persistent task goals");
632
633
    // Pause goal
634
    await session.submit("/goal pause");
635
    expect(store.getGoal()?.status).toBe("paused");
636
637
    // Resume goal
638
    await session.submit("/goal resume");
639
    expect(store.getGoal()?.status).toBe("active");
640
641
    // Clear goal
642
    await session.submit("/goal clear");
643
    expect(store.getGoal()).toBeUndefined();
644
    expect(session.snapshot().goal).toBeUndefined();
645
  });
646
});

This page updates live while a promote is in flight · changelog