From a3f316593049e00b7076915156461c33afa669b0 Mon Sep 17 00:00:00 2001 From: Ercin Dedeoglu Date: Wed, 2 Sep 2026 08:24:27 +0400 Subject: [PATCH 1/2] feat: configurable max_objective_chars option for objective/evidence/blocker limits The 4000-character limit on goal objectives, completion evidence, and blockers was hardcoded in validateObjective/validateEvidence and in the create_goal / set_goal / update_goal_objective / update_goal tool schemas (zod max() and maxLength). The schema-side limit also causes clients to silently truncate long objectives before the tool is even called. - Add a max_objective_chars plugin option (default 100000, up from 4000). - Thread it through runtime validation and both V1 (zod) and V2 (JSON schema) tool registrations so runtime and advertised limits always match. - Update the /goal command template to ask for a complete, faithful objective (no compressing/truncating, no substitution with references to external files) instead of the weaker "use the full arguments" wording. - Update the boundary test to exercise the configured limit and reset the module-global limit between tests. Fixes #37 --- README.md | 4 +++- dist/server.js | 43 ++++++++++++++++++++++++++++--------------- src/server.ts | 28 +++++++++++++++++----------- src/state.ts | 18 ++++++++++++++++-- test/server.test.ts | 5 ++++- 5 files changed, 68 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 8c1bed7..7165803 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,8 @@ In OpenCode 1, server options use the package-and-options tuple in `opencode.jso "no_progress_token_threshold": 50, "max_no_progress_turns": 2, "restricted_agents": ["plan"], - "allow_goal_execution_from_plan": false + "allow_goal_execution_from_plan": false, + "max_objective_chars": 100000 } ] ] @@ -169,6 +170,7 @@ Defaults: - `command_name`: `"goal"` - `restricted_agents`: `["plan"]`; agents (matched case-insensitively) treated as planning-only for goal execution. - `allow_goal_execution_from_plan`: `false`; when `true`, disables Plan-mode goal restrictions entirely. +- `max_objective_chars`: `100000`; maximum length of the goal objective, completion evidence, and blocker text. Applies to both the runtime validation and the tool schemas, so clients do not truncate long objectives before the tool is called. ## Goal Workflow diff --git a/dist/server.js b/dist/server.js index 2fbca64..d7b4bcb 100644 --- a/dist/server.js +++ b/dist/server.js @@ -288,20 +288,31 @@ async function mutate(fn) { })); }); } +var DEFAULT_MAX_OBJECTIVE_CHARS = 1e5; +var objectiveCharLimit = DEFAULT_MAX_OBJECTIVE_CHARS; +function configureMaxObjectiveChars(value) { + objectiveCharLimit = typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : DEFAULT_MAX_OBJECTIVE_CHARS; + return objectiveCharLimit; +} +function maxObjectiveChars() { + return objectiveCharLimit; +} function validateObjective(objective) { const value = objective.trim(); if (!value) throw new Error("goal objective must not be empty"); - if ([...value].length > 4000) - throw new Error("goal objective must be at most 4000 characters"); + const limit = maxObjectiveChars(); + if ([...value].length > limit) + throw new Error(`goal objective must be at most ${limit} characters`); return value; } function validateEvidence(evidence, label) { const value = evidence?.trim(); if (!value) throw new Error(`${label} must not be empty`); - if ([...value].length > 4000) - throw new Error(`${label} must be at most 4000 characters`); + const limit = maxObjectiveChars(); + if ([...value].length > limit) + throw new Error(`${label} must be at most ${limit} characters`); return value; } function normalizeState(state) { @@ -1199,7 +1210,7 @@ Use the goal tools to handle this command: - If the arguments start with "edit ", update the current goal objective by calling update_goal_objective with the remaining text. - If the arguments start with "complete " or "done ", perform a completion audit against real artifacts and command output. Call update_goal with status "complete" only if the goal is achieved, using concise evidence from the audit. - If the arguments start with "unmet ", "blocked ", or "blocker ", call update_goal with status "unmet" only when the goal cannot be achieved or needs external input, using the remaining arguments as the blocker. -- Otherwise, call get_goal first. If it returns a non-closed goal with the same objective, do not create it again; continue working from the returned state. If it returns a different non-closed goal, report that conflict instead of replacing it. Only when there is no non-closed goal, call create_goal once. Use the full arguments as the objective. If the user includes explicit budget instructions, pass token_budget, max_auto_turns, or max_duration_seconds to create_goal rather than leaving those words in the objective. +- Otherwise, call get_goal first. If it returns a non-closed goal with the same objective, do not create it again; continue working from the returned state. If it returns a different non-closed goal, report that conflict instead of replacing it. Only when there is no non-closed goal, call create_goal once. Build the objective as a complete, faithful representation of the arguments: keep every requirement, constraint, scope boundary, and success criterion with no omissions or loss of meaning. You may restructure and rephrase for clarity and coherence, but do NOT compress, truncate, or drop any content, and do NOT substitute the content with references or pointers to external files. If the user includes explicit budget instructions, pass token_budget, max_auto_turns, or max_duration_seconds to create_goal rather than leaving those words in the objective. Create a goal only from these explicit command arguments. Do not infer a goal from unrelated session context. After create_goal succeeds or returns an existing matching goal, never call it again for this command; continue working from the returned goal state.`; } @@ -1905,6 +1916,7 @@ var server = async ({ client }, options) => { const maxPromptFailures = positiveIntegerOrNull2(options?.max_prompt_failures) ?? DEFAULT_MAX_PROMPT_FAILURES; const registerCommand = options?.register_command ?? true; const commandName = commandNameFromOptions(options); + const objectiveChars = configureMaxObjectiveChars(positiveIntegerOrNull2(options?.max_objective_chars) ?? DEFAULT_MAX_OBJECTIVE_CHARS); const taskTracker = new TaskTracker; const taskDeferredSessions = new Set; const scheduledContinuations = new Map; @@ -2207,7 +2219,7 @@ var server = async ({ client }, options) => { create_goal: { description: "Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks. If any non-closed goal exists, this returns the existing goal as either reused or conflicting and must not be retried. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.", args: { - objective: z.string().min(1).max(4000).describe("The concrete objective to start pursuing."), + objective: z.string().min(1).max(objectiveChars).describe("The concrete objective to start pursuing."), token_budget: z.number().int().positive().nullable().optional().describe("Optional positive token budget."), max_auto_turns: z.number().int().positive().nullable().optional().describe("Optional per-goal auto-continue limit."), max_duration_seconds: z.number().int().positive().nullable().optional().describe("Optional per-goal duration limit.") @@ -2219,7 +2231,7 @@ var server = async ({ client }, options) => { set_goal: { description: "Set a new goal when the user explicitly asks the agent to formulate and set its own goal. The model should write the objective itself based on the user's explicit request. If any non-closed goal exists, this returns the existing goal as either reused or conflicting and must not be retried. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.", args: { - objective: z.string().min(1).max(4000).describe("The model-formulated concrete objective to start pursuing."), + objective: z.string().min(1).max(objectiveChars).describe("The model-formulated concrete objective to start pursuing."), token_budget: z.number().int().positive().nullable().optional().describe("Optional positive token budget."), max_auto_turns: z.number().int().positive().nullable().optional().describe("Optional per-goal auto-continue limit."), max_duration_seconds: z.number().int().positive().nullable().optional().describe("Optional per-goal duration limit.") @@ -2231,7 +2243,7 @@ var server = async ({ client }, options) => { update_goal_objective: { description: "Edit the current OpenCode goal objective when the user explicitly asks to edit or replace it.", args: { - objective: z.string().min(1).max(4000).describe("The updated concrete objective."), + objective: z.string().min(1).max(objectiveChars).describe("The updated concrete objective."), status: z.enum(["active", "paused"]).optional().describe("Whether the edited goal should be active or paused.") }, async execute(args, context) { @@ -2242,8 +2254,8 @@ var server = async ({ client }, options) => { description: "Close the existing goal only after an audit against real evidence. Use status complete only when the objective is achieved and no required work remains, and include evidence. Use status unmet only when the objective cannot be achieved or is blocked, and include the blocker. Do not close a goal merely because work is stopping.", args: { status: z.enum(["complete", "unmet"]).describe("Required. complete means achieved; unmet means blocked or impossible."), - evidence: z.string().min(1).max(4000).optional().describe("Required when status is complete. Summarize the concrete evidence verified."), - blocker: z.string().min(1).max(4000).optional().describe("Required when status is unmet. Explain the concrete blocker or impossibility.") + evidence: z.string().min(1).max(objectiveChars).optional().describe("Required when status is complete. Summarize the concrete evidence verified."), + blocker: z.string().min(1).max(objectiveChars).optional().describe("Required when status is unmet. Explain the concrete blocker or impossibility.") }, async execute(args, context) { return closeGoalFromTool(args, context); @@ -2453,6 +2465,7 @@ async function setupV2(context) { const maxPromptFailures = positiveIntegerOrNull2(options.max_prompt_failures) ?? DEFAULT_MAX_PROMPT_FAILURES; const registerCommand = options.register_command ?? true; const commandName = commandNameFromOptions(options); + configureMaxObjectiveChars(positiveIntegerOrNull2(options.max_objective_chars) ?? DEFAULT_MAX_OBJECTIVE_CHARS); const taskTracker = new TaskTracker; const taskDeferredSessions = new Set; const scheduledContinuations = new Map; @@ -3092,7 +3105,7 @@ function goalToolsV2(services) { name: "create_goal", description: "Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks. If any non-closed goal exists, this returns the existing goal as either reused or conflicting and must not be retried. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.", input: v2ObjectSchema({ - objective: { type: "string", minLength: 1, maxLength: 4000, description: "The concrete objective to start pursuing." }, + objective: { type: "string", minLength: 1, maxLength: maxObjectiveChars(), description: "The concrete objective to start pursuing." }, token_budget: { type: ["integer", "null"], minimum: 1, description: "Optional positive token budget." }, max_auto_turns: { type: ["integer", "null"], minimum: 1, description: "Optional per-goal auto-continue limit." }, max_duration_seconds: { type: ["integer", "null"], minimum: 1, description: "Optional per-goal duration limit." } @@ -3109,7 +3122,7 @@ function goalToolsV2(services) { objective: { type: "string", minLength: 1, - maxLength: 4000, + maxLength: maxObjectiveChars(), description: "The model-formulated concrete objective to start pursuing." }, token_budget: { type: ["integer", "null"], minimum: 1, description: "Optional positive token budget." }, @@ -3125,7 +3138,7 @@ function goalToolsV2(services) { name: "update_goal_objective", description: "Edit the current OpenCode goal objective when the user explicitly asks to edit or replace it.", input: v2ObjectSchema({ - objective: { type: "string", minLength: 1, maxLength: 4000, description: "The updated concrete objective." }, + objective: { type: "string", minLength: 1, maxLength: maxObjectiveChars(), description: "The updated concrete objective." }, status: { type: "string", enum: ["active", "paused"], description: "Whether the edited goal should be active or paused." } }, ["objective"]), options: { codemode: false }, @@ -3145,13 +3158,13 @@ function goalToolsV2(services) { evidence: { type: "string", minLength: 1, - maxLength: 4000, + maxLength: maxObjectiveChars(), description: "Required when status is complete. Summarize the concrete evidence verified." }, blocker: { type: "string", minLength: 1, - maxLength: 4000, + maxLength: maxObjectiveChars(), description: "Required when status is unmet. Explain the concrete blocker or impossibility." } }, ["status"]), diff --git a/src/server.ts b/src/server.ts index c63c4a3..cac3927 100644 --- a/src/server.ts +++ b/src/server.ts @@ -8,13 +8,16 @@ import { accountUsage, clearGoal, completeGoal, + configureMaxObjectiveChars, createGoal, + DEFAULT_MAX_OBJECTIVE_CHARS, estimateTokensFromText, formatGoalHistory, getAllGoals, getGoal, getGoalInternal, markGoalUnmet, + maxObjectiveChars, pauseGoalForPlanMode, PLAN_MODE_STOP_REASON, recordAssistantProgress, @@ -45,6 +48,7 @@ type Options = { max_no_progress_turns?: number restricted_agents?: string[] allow_goal_execution_from_plan?: boolean + max_objective_chars?: number } type CreateGoalArgs = { @@ -154,7 +158,7 @@ Use the goal tools to handle this command: - If the arguments start with "edit ", update the current goal objective by calling update_goal_objective with the remaining text. - If the arguments start with "complete " or "done ", perform a completion audit against real artifacts and command output. Call update_goal with status "complete" only if the goal is achieved, using concise evidence from the audit. - If the arguments start with "unmet ", "blocked ", or "blocker ", call update_goal with status "unmet" only when the goal cannot be achieved or needs external input, using the remaining arguments as the blocker. -- Otherwise, call get_goal first. If it returns a non-closed goal with the same objective, do not create it again; continue working from the returned state. If it returns a different non-closed goal, report that conflict instead of replacing it. Only when there is no non-closed goal, call create_goal once. Use the full arguments as the objective. If the user includes explicit budget instructions, pass token_budget, max_auto_turns, or max_duration_seconds to create_goal rather than leaving those words in the objective. +- Otherwise, call get_goal first. If it returns a non-closed goal with the same objective, do not create it again; continue working from the returned state. If it returns a different non-closed goal, report that conflict instead of replacing it. Only when there is no non-closed goal, call create_goal once. Build the objective as a complete, faithful representation of the arguments: keep every requirement, constraint, scope boundary, and success criterion with no omissions or loss of meaning. You may restructure and rephrase for clarity and coherence, but do NOT compress, truncate, or drop any content, and do NOT substitute the content with references or pointers to external files. If the user includes explicit budget instructions, pass token_budget, max_auto_turns, or max_duration_seconds to create_goal rather than leaving those words in the objective. Create a goal only from these explicit command arguments. Do not infer a goal from unrelated session context. After create_goal succeeds or returns an existing matching goal, never call it again for this command; continue working from the returned goal state.` } @@ -925,6 +929,7 @@ const server: Plugin = async ({ client }, options?: Options) => { const maxPromptFailures = positiveIntegerOrNull(options?.max_prompt_failures) ?? DEFAULT_MAX_PROMPT_FAILURES const registerCommand = options?.register_command ?? true const commandName = commandNameFromOptions(options) + const objectiveChars = configureMaxObjectiveChars(positiveIntegerOrNull(options?.max_objective_chars) ?? DEFAULT_MAX_OBJECTIVE_CHARS) const taskTracker = new TaskTracker() const taskDeferredSessions = new Set() const scheduledContinuations = new Map() @@ -1278,7 +1283,7 @@ const server: Plugin = async ({ client }, options?: Options) => { description: "Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks. If any non-closed goal exists, this returns the existing goal as either reused or conflicting and must not be retried. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.", args: { - objective: z.string().min(1).max(4000).describe("The concrete objective to start pursuing."), + objective: z.string().min(1).max(objectiveChars).describe("The concrete objective to start pursuing."), token_budget: z.number().int().positive().nullable().optional().describe("Optional positive token budget."), max_auto_turns: z.number().int().positive().nullable().optional().describe("Optional per-goal auto-continue limit."), max_duration_seconds: z.number().int().positive().nullable().optional().describe("Optional per-goal duration limit."), @@ -1291,7 +1296,7 @@ const server: Plugin = async ({ client }, options?: Options) => { description: "Set a new goal when the user explicitly asks the agent to formulate and set its own goal. The model should write the objective itself based on the user's explicit request. If any non-closed goal exists, this returns the existing goal as either reused or conflicting and must not be retried. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.", args: { - objective: z.string().min(1).max(4000).describe("The model-formulated concrete objective to start pursuing."), + objective: z.string().min(1).max(objectiveChars).describe("The model-formulated concrete objective to start pursuing."), token_budget: z.number().int().positive().nullable().optional().describe("Optional positive token budget."), max_auto_turns: z.number().int().positive().nullable().optional().describe("Optional per-goal auto-continue limit."), max_duration_seconds: z.number().int().positive().nullable().optional().describe("Optional per-goal duration limit."), @@ -1303,7 +1308,7 @@ const server: Plugin = async ({ client }, options?: Options) => { update_goal_objective: { description: "Edit the current OpenCode goal objective when the user explicitly asks to edit or replace it.", args: { - objective: z.string().min(1).max(4000).describe("The updated concrete objective."), + objective: z.string().min(1).max(objectiveChars).describe("The updated concrete objective."), status: z.enum(["active", "paused"]).optional().describe("Whether the edited goal should be active or paused."), }, async execute(args, context) { @@ -1318,13 +1323,13 @@ const server: Plugin = async ({ client }, options?: Options) => { evidence: z .string() .min(1) - .max(4000) + .max(objectiveChars) .optional() .describe("Required when status is complete. Summarize the concrete evidence verified."), blocker: z .string() .min(1) - .max(4000) + .max(objectiveChars) .optional() .describe("Required when status is unmet. Explain the concrete blocker or impossibility."), }, @@ -1554,6 +1559,7 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise() const scheduledContinuations = new Map() @@ -2233,7 +2239,7 @@ function goalToolsV2(services: GoalServices): ToolV2Info[] { "Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks. If any non-closed goal exists, this returns the existing goal as either reused or conflicting and must not be retried. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.", input: v2ObjectSchema( { - objective: { type: "string", minLength: 1, maxLength: 4000, description: "The concrete objective to start pursuing." }, + objective: { type: "string", minLength: 1, maxLength: maxObjectiveChars(), description: "The concrete objective to start pursuing." }, token_budget: { type: ["integer", "null"], minimum: 1, description: "Optional positive token budget." }, max_auto_turns: { type: ["integer", "null"], minimum: 1, description: "Optional per-goal auto-continue limit." }, max_duration_seconds: { type: ["integer", "null"], minimum: 1, description: "Optional per-goal duration limit." }, @@ -2254,7 +2260,7 @@ function goalToolsV2(services: GoalServices): ToolV2Info[] { objective: { type: "string", minLength: 1, - maxLength: 4000, + maxLength: maxObjectiveChars(), description: "The model-formulated concrete objective to start pursuing.", }, token_budget: { type: ["integer", "null"], minimum: 1, description: "Optional positive token budget." }, @@ -2273,7 +2279,7 @@ function goalToolsV2(services: GoalServices): ToolV2Info[] { description: "Edit the current OpenCode goal objective when the user explicitly asks to edit or replace it.", input: v2ObjectSchema( { - objective: { type: "string", minLength: 1, maxLength: 4000, description: "The updated concrete objective." }, + objective: { type: "string", minLength: 1, maxLength: maxObjectiveChars(), description: "The updated concrete objective." }, status: { type: "string", enum: ["active", "paused"], description: "Whether the edited goal should be active or paused." }, }, ["objective"], @@ -2297,13 +2303,13 @@ function goalToolsV2(services: GoalServices): ToolV2Info[] { evidence: { type: "string", minLength: 1, - maxLength: 4000, + maxLength: maxObjectiveChars(), description: "Required when status is complete. Summarize the concrete evidence verified.", }, blocker: { type: "string", minLength: 1, - maxLength: 4000, + maxLength: maxObjectiveChars(), description: "Required when status is unmet. Explain the concrete blocker or impossibility.", }, }, diff --git a/src/state.ts b/src/state.ts index 63b68c2..8be1f28 100644 --- a/src/state.ts +++ b/src/state.ts @@ -401,17 +401,31 @@ async function mutate(fn: (state: State) => T | Promise) { }) } +export const DEFAULT_MAX_OBJECTIVE_CHARS = 100_000 +let objectiveCharLimit = DEFAULT_MAX_OBJECTIVE_CHARS + +export function configureMaxObjectiveChars(value: number | null | undefined) { + objectiveCharLimit = typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : DEFAULT_MAX_OBJECTIVE_CHARS + return objectiveCharLimit +} + +export function maxObjectiveChars() { + return objectiveCharLimit +} + export function validateObjective(objective: string) { const value = objective.trim() if (!value) throw new Error("goal objective must not be empty") - if ([...value].length > 4000) throw new Error("goal objective must be at most 4000 characters") + const limit = maxObjectiveChars() + if ([...value].length > limit) throw new Error(`goal objective must be at most ${limit} characters`) return value } export function validateEvidence(evidence: string | null | undefined, label: string) { const value = evidence?.trim() if (!value) throw new Error(`${label} must not be empty`) - if ([...value].length > 4000) throw new Error(`${label} must be at most 4000 characters`) + const limit = maxObjectiveChars() + if ([...value].length > limit) throw new Error(`${label} must be at most ${limit} characters`) return value } diff --git a/test/server.test.ts b/test/server.test.ts index f89720b..149f75c 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -5,6 +5,8 @@ import { tmpdir } from "node:os" import plugin from "../src/server" import { accountUsage, + configureMaxObjectiveChars, + DEFAULT_MAX_OBJECTIVE_CHARS, getGoal, getGoalInternal, recordContinuationResult, @@ -57,6 +59,7 @@ beforeEach(async () => { afterEach(async () => { for (const dispose of serverDisposers.splice(0).reverse()) await dispose() + configureMaxObjectiveChars(DEFAULT_MAX_OBJECTIVE_CHARS) delete process.env.OPENCODE_GOAL_STATE_PATH await rm(dir, { recursive: true, force: true }) }) @@ -160,7 +163,7 @@ test("set goal lets the agent formulate the goal objective", async () => { test("create_goal reuses the same active objective without mutating state", async () => { const hooks = await setupServer( { client: { session: { promptAsync: async () => {} } } } as never, - { auto_continue: false }, + { auto_continue: false, max_objective_chars: 4000 }, ) const tools = hooks.tool! const context = { sessionID: "ses_1" } as never From 996188a505cbcf90694a84c22d078c33fcb4ac6a Mon Sep 17 00:00:00 2001 From: Ercin Dedeoglu Date: Thu, 3 Sep 2026 07:32:40 +0400 Subject: [PATCH 2/2] fix: scope max_objective_chars per plugin instance Address review on #38: - Capture the limit per V1/V2 setup instead of a process-wide setter so concurrent instances keep matching schemas and runtime validation. - Count trimmed Unicode code points in V1 Zod, V2 JSON Schema, and runtime validation (emoji and surrounding whitespace now agree). - Cover custom/default limits, advertised schema max, evidence/blocker, simultaneous instances, Unicode/whitespace, and command wording. - Keep the 100000 default as an intentional replacement for the 4000 defect; document that large objectives are echoed into later prompts. --- README.md | 2 +- dist/server.js | 135 ++++++++++++++++++++--------------------- src/server.ts | 114 ++++++++++++++++++++-------------- src/state.ts | 52 ++++++++-------- test/server-v2.test.ts | 61 +++++++++++++++++++ test/server.test.ts | 89 +++++++++++++++++++++++++-- test/state.test.ts | 24 ++++++++ 7 files changed, 327 insertions(+), 150 deletions(-) diff --git a/README.md b/README.md index 7165803..9cefa23 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ Defaults: - `command_name`: `"goal"` - `restricted_agents`: `["plan"]`; agents (matched case-insensitively) treated as planning-only for goal execution. - `allow_goal_execution_from_plan`: `false`; when `true`, disables Plan-mode goal restrictions entirely. -- `max_objective_chars`: `100000`; maximum length of the goal objective, completion evidence, and blocker text. Applies to both the runtime validation and the tool schemas, so clients do not truncate long objectives before the tool is called. +- `max_objective_chars`: `100000`; maximum Unicode code-point length (after trimming) of the goal objective, completion evidence, and blocker text. The previous 4000-character cap was a defect, not a compatibility constraint. The same limit is advertised on V1 and V2 tool schemas and enforced at runtime, independently per plugin instance. Large objectives are echoed into continuation and compaction prompts. ## Goal Workflow diff --git a/dist/server.js b/dist/server.js index d7b4bcb..00ecceb 100644 --- a/dist/server.js +++ b/dist/server.js @@ -289,31 +289,22 @@ async function mutate(fn) { }); } var DEFAULT_MAX_OBJECTIVE_CHARS = 1e5; -var objectiveCharLimit = DEFAULT_MAX_OBJECTIVE_CHARS; -function configureMaxObjectiveChars(value) { - objectiveCharLimit = typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : DEFAULT_MAX_OBJECTIVE_CHARS; - return objectiveCharLimit; +function resolveMaxObjectiveChars(value) { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : DEFAULT_MAX_OBJECTIVE_CHARS; } -function maxObjectiveChars() { - return objectiveCharLimit; -} -function validateObjective(objective) { - const value = objective.trim(); - if (!value) - throw new Error("goal objective must not be empty"); - const limit = maxObjectiveChars(); - if ([...value].length > limit) - throw new Error(`goal objective must be at most ${limit} characters`); - return value; -} -function validateEvidence(evidence, label) { - const value = evidence?.trim(); - if (!value) +function boundedText(value, limit, label) { + const trimmed = value.trim(); + if (!trimmed) throw new Error(`${label} must not be empty`); - const limit = maxObjectiveChars(); - if ([...value].length > limit) + if ([...trimmed].length > limit) throw new Error(`${label} must be at most ${limit} characters`); - return value; + return trimmed; +} +function validateObjective(objective, limit = DEFAULT_MAX_OBJECTIVE_CHARS) { + return boundedText(objective, limit, "goal objective"); +} +function validateEvidence(evidence, label, limit = DEFAULT_MAX_OBJECTIVE_CHARS) { + return boundedText(evidence ?? "", limit, label); } function normalizeState(state) { for (const goal of Object.values(state.goals)) @@ -389,7 +380,8 @@ function normalizeCreateOptions(input) { noProgressTokenThreshold: DEFAULT_NO_PROGRESS_TOKEN_THRESHOLD, maxNoProgressTurns: DEFAULT_MAX_NO_PROGRESS_TURNS, agent: null, - initialStatus: "active" + initialStatus: "active", + maxObjectiveChars: DEFAULT_MAX_OBJECTIVE_CHARS }; } return { @@ -399,7 +391,8 @@ function normalizeCreateOptions(input) { noProgressTokenThreshold: positiveIntegerOrNull(input?.noProgressTokenThreshold) ?? DEFAULT_NO_PROGRESS_TOKEN_THRESHOLD, maxNoProgressTurns: positiveIntegerOrNull(input?.maxNoProgressTurns) ?? DEFAULT_MAX_NO_PROGRESS_TURNS, agent: typeof input?.agent === "string" && input.agent.trim() ? input.agent.trim() : null, - initialStatus: input?.initialStatus === "paused" ? "paused" : "active" + initialStatus: input?.initialStatus === "paused" ? "paused" : "active", + maxObjectiveChars: resolveMaxObjectiveChars(input?.maxObjectiveChars) }; } function positiveIntegerOrNull(value) { @@ -499,8 +492,8 @@ async function getGoalInternal(sessionID) { return goal ? snapshotInternal(goal) : null; } async function createGoal(sessionID, objective, options) { - const value = validateObjective(objective); const normalizedOptions = normalizeCreateOptions(options); + const value = validateObjective(objective, resolveMaxObjectiveChars(normalizedOptions.maxObjectiveChars)); return mutate((state) => { const existing = state.goals[sessionID]; if (existing && !isClosed(existing.status)) { @@ -552,7 +545,7 @@ async function createGoal(sessionID, objective, options) { }); } async function updateGoalObjective(sessionID, objective, status = "active", options) { - const value = validateObjective(objective); + const value = validateObjective(objective, resolveMaxObjectiveChars(options?.maxObjectiveChars)); const agent = typeof options?.agent === "string" && options.agent.trim() ? options.agent.trim() : null; const planModePause = options?.planModePause === true; return mutate((state) => { @@ -637,7 +630,8 @@ async function setGoalStatus(sessionID, status, agent) { return snapshot(goal); }); } -async function closeGoal(sessionID, input) { +async function closeGoal(sessionID, input, maxObjectiveChars = DEFAULT_MAX_OBJECTIVE_CHARS) { + const limit = resolveMaxObjectiveChars(maxObjectiveChars); return mutate((state) => { const goal = state.goals[sessionID]; if (!goal) @@ -650,12 +644,12 @@ async function closeGoal(sessionID, input) { goal.lastAccountedAt = null; goal.stopReason = input.status === "complete" ? null : "blocked"; if (input.status === "complete") { - goal.completionEvidence = validateEvidence(input.evidence, "completion evidence"); + goal.completionEvidence = validateEvidence(input.evidence, "completion evidence", limit); goal.blocker = null; goal.lastStatus = "Goal completed."; pushHistory(goal, "completed", goal.completionEvidence); } else { - goal.blocker = validateEvidence(input.blocker, "blocker"); + goal.blocker = validateEvidence(input.blocker, "blocker", limit); goal.completionEvidence = null; goal.lastStatus = "Goal marked unmet."; pushHistory(goal, "unmet", goal.blocker); @@ -663,11 +657,11 @@ async function closeGoal(sessionID, input) { return snapshot(goal); }); } -async function completeGoal(sessionID, evidence) { - return closeGoal(sessionID, { status: "complete", evidence }); +async function completeGoal(sessionID, evidence, maxObjectiveChars = DEFAULT_MAX_OBJECTIVE_CHARS) { + return closeGoal(sessionID, { status: "complete", evidence }, maxObjectiveChars); } -async function markGoalUnmet(sessionID, blocker) { - return closeGoal(sessionID, { status: "unmet", blocker }); +async function markGoalUnmet(sessionID, blocker, maxObjectiveChars = DEFAULT_MAX_OBJECTIVE_CHARS) { + return closeGoal(sessionID, { status: "unmet", blocker }, maxObjectiveChars); } async function clearGoal(sessionID) { return mutate((state) => { @@ -1811,9 +1805,24 @@ function getGoalToolResult(goal) { } return JSON.stringify(result, null, 2); } +function boundedGoalTextSchema(limit, description, validate) { + return z.string().superRefine((value, ctx) => { + try { + validate(value); + } catch (error) { + ctx.addIssue({ + code: "custom", + message: error instanceof Error ? error.message : String(error) + }); + } + }).meta({ minLength: 1, maxLength: limit, description }); +} +function v2GoalTextSchema(limit, description) { + return { type: "string", minLength: 1, maxLength: limit, description }; +} async function createGoalFromTool(input, context, services) { const planningOnly = services.isPlanAgent(context.agent); - const objective = validateObjective(input.objective); + const objective = validateObjective(input.objective, services.maxObjectiveChars); const existing = await getGoal(context.sessionID); if (existing && !isClosedGoal(existing)) return existingGoalResult(existing, objective, planningOnly); @@ -1826,7 +1835,8 @@ async function createGoalFromTool(input, context, services) { noProgressTokenThreshold: services.options.no_progress_token_threshold ?? null, maxNoProgressTurns: services.options.max_no_progress_turns ?? null, agent: typeof context.agent === "string" ? context.agent : null, - initialStatus: planningOnly ? "paused" : "active" + initialStatus: planningOnly ? "paused" : "active", + maxObjectiveChars: services.maxObjectiveChars }); } catch (error) { if (!(error instanceof Error) || !error.message.includes("non-closed goal")) @@ -1856,18 +1866,19 @@ async function updateGoalObjectiveFromTool(input, context, services) { const planningOnly = requested === "active" && services.isPlanAgent(context.agent); const goal = await updateGoalObjective(context.sessionID, input.objective, planningOnly ? "paused" : requested, { agent: typeof context.agent === "string" ? context.agent : null, - planModePause: planningOnly + planModePause: planningOnly, + maxObjectiveChars: services.maxObjectiveChars }); return JSON.stringify(planningOnly ? { goal, plan_mode_notice: PLAN_MODE_CREATE_NOTICE } : { goal }, null, 2); } -async function closeGoalFromTool(input, context) { +async function closeGoalFromTool(input, context, services) { if (input.status === "complete") { - const goal2 = await completeGoal(context.sessionID, input.evidence ?? ""); + const goal2 = await completeGoal(context.sessionID, input.evidence ?? "", services.maxObjectiveChars); const budget = goal2.tokenBudget == null ? "" : ` Token usage: ${goal2.tokensUsed}/${goal2.tokenBudget}.`; const report2 = `Goal achieved. Time used: ${goal2.timeUsedSeconds} seconds.${budget} Evidence: ${goal2.completionEvidence}.`; return JSON.stringify({ goal: goal2, completion_report: report2 }, null, 2); } - const goal = await markGoalUnmet(context.sessionID, input.blocker ?? ""); + const goal = await markGoalUnmet(context.sessionID, input.blocker ?? "", services.maxObjectiveChars); const report = `Goal unmet. Time used: ${goal.timeUsedSeconds} seconds. Blocker: ${goal.blocker}.`; return JSON.stringify({ goal, unmet_report: report }, null, 2); } @@ -1916,7 +1927,7 @@ var server = async ({ client }, options) => { const maxPromptFailures = positiveIntegerOrNull2(options?.max_prompt_failures) ?? DEFAULT_MAX_PROMPT_FAILURES; const registerCommand = options?.register_command ?? true; const commandName = commandNameFromOptions(options); - const objectiveChars = configureMaxObjectiveChars(positiveIntegerOrNull2(options?.max_objective_chars) ?? DEFAULT_MAX_OBJECTIVE_CHARS); + const objectiveChars = resolveMaxObjectiveChars(options?.max_objective_chars); const taskTracker = new TaskTracker; const taskDeferredSessions = new Set; const scheduledContinuations = new Map; @@ -1928,7 +1939,7 @@ var server = async ({ client }, options) => { const watchdogRescuedSessions = new Set; const planAgents = restrictedAgentSet(options); const isPlanAgent = (agent) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase()); - const goalServices = { options: options ?? {}, isPlanAgent }; + const goalServices = { options: options ?? {}, isPlanAgent, maxObjectiveChars: objectiveChars }; let disposed = false; async function taskBlockStatus(sessionID) { if (!deferWhileTasksActive) @@ -2219,7 +2230,7 @@ var server = async ({ client }, options) => { create_goal: { description: "Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks. If any non-closed goal exists, this returns the existing goal as either reused or conflicting and must not be retried. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.", args: { - objective: z.string().min(1).max(objectiveChars).describe("The concrete objective to start pursuing."), + objective: boundedGoalTextSchema(objectiveChars, "The concrete objective to start pursuing.", (value) => validateObjective(value, objectiveChars)), token_budget: z.number().int().positive().nullable().optional().describe("Optional positive token budget."), max_auto_turns: z.number().int().positive().nullable().optional().describe("Optional per-goal auto-continue limit."), max_duration_seconds: z.number().int().positive().nullable().optional().describe("Optional per-goal duration limit.") @@ -2231,7 +2242,7 @@ var server = async ({ client }, options) => { set_goal: { description: "Set a new goal when the user explicitly asks the agent to formulate and set its own goal. The model should write the objective itself based on the user's explicit request. If any non-closed goal exists, this returns the existing goal as either reused or conflicting and must not be retried. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.", args: { - objective: z.string().min(1).max(objectiveChars).describe("The model-formulated concrete objective to start pursuing."), + objective: boundedGoalTextSchema(objectiveChars, "The model-formulated concrete objective to start pursuing.", (value) => validateObjective(value, objectiveChars)), token_budget: z.number().int().positive().nullable().optional().describe("Optional positive token budget."), max_auto_turns: z.number().int().positive().nullable().optional().describe("Optional per-goal auto-continue limit."), max_duration_seconds: z.number().int().positive().nullable().optional().describe("Optional per-goal duration limit.") @@ -2243,7 +2254,7 @@ var server = async ({ client }, options) => { update_goal_objective: { description: "Edit the current OpenCode goal objective when the user explicitly asks to edit or replace it.", args: { - objective: z.string().min(1).max(objectiveChars).describe("The updated concrete objective."), + objective: boundedGoalTextSchema(objectiveChars, "The updated concrete objective.", (value) => validateObjective(value, objectiveChars)), status: z.enum(["active", "paused"]).optional().describe("Whether the edited goal should be active or paused.") }, async execute(args, context) { @@ -2254,11 +2265,11 @@ var server = async ({ client }, options) => { description: "Close the existing goal only after an audit against real evidence. Use status complete only when the objective is achieved and no required work remains, and include evidence. Use status unmet only when the objective cannot be achieved or is blocked, and include the blocker. Do not close a goal merely because work is stopping.", args: { status: z.enum(["complete", "unmet"]).describe("Required. complete means achieved; unmet means blocked or impossible."), - evidence: z.string().min(1).max(objectiveChars).optional().describe("Required when status is complete. Summarize the concrete evidence verified."), - blocker: z.string().min(1).max(objectiveChars).optional().describe("Required when status is unmet. Explain the concrete blocker or impossibility.") + evidence: boundedGoalTextSchema(objectiveChars, "Required when status is complete. Summarize the concrete evidence verified.", (value) => validateEvidence(value, "completion evidence", objectiveChars)).optional(), + blocker: boundedGoalTextSchema(objectiveChars, "Required when status is unmet. Explain the concrete blocker or impossibility.", (value) => validateEvidence(value, "blocker", objectiveChars)).optional() }, async execute(args, context) { - return closeGoalFromTool(args, context); + return closeGoalFromTool(args, context, goalServices); } }, update_goal_status: { @@ -2465,7 +2476,7 @@ async function setupV2(context) { const maxPromptFailures = positiveIntegerOrNull2(options.max_prompt_failures) ?? DEFAULT_MAX_PROMPT_FAILURES; const registerCommand = options.register_command ?? true; const commandName = commandNameFromOptions(options); - configureMaxObjectiveChars(positiveIntegerOrNull2(options.max_objective_chars) ?? DEFAULT_MAX_OBJECTIVE_CHARS); + const objectiveChars = resolveMaxObjectiveChars(options.max_objective_chars); const taskTracker = new TaskTracker; const taskDeferredSessions = new Set; const scheduledContinuations = new Map; @@ -2483,6 +2494,7 @@ async function setupV2(context) { const stepTokenSums = new Map; const goalServices = { options, + maxObjectiveChars: objectiveChars, isPlanAgent, initializeUsage: async (sessionID) => { try { @@ -3105,7 +3117,7 @@ function goalToolsV2(services) { name: "create_goal", description: "Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks. If any non-closed goal exists, this returns the existing goal as either reused or conflicting and must not be retried. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.", input: v2ObjectSchema({ - objective: { type: "string", minLength: 1, maxLength: maxObjectiveChars(), description: "The concrete objective to start pursuing." }, + objective: v2GoalTextSchema(services.maxObjectiveChars, "The concrete objective to start pursuing."), token_budget: { type: ["integer", "null"], minimum: 1, description: "Optional positive token budget." }, max_auto_turns: { type: ["integer", "null"], minimum: 1, description: "Optional per-goal auto-continue limit." }, max_duration_seconds: { type: ["integer", "null"], minimum: 1, description: "Optional per-goal duration limit." } @@ -3119,12 +3131,7 @@ function goalToolsV2(services) { name: "set_goal", description: "Set a new goal when the user explicitly asks the agent to formulate and set its own goal. The model should write the objective itself based on the user's explicit request. If any non-closed goal exists, this returns the existing goal as either reused or conflicting and must not be retried. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.", input: v2ObjectSchema({ - objective: { - type: "string", - minLength: 1, - maxLength: maxObjectiveChars(), - description: "The model-formulated concrete objective to start pursuing." - }, + objective: v2GoalTextSchema(services.maxObjectiveChars, "The model-formulated concrete objective to start pursuing."), token_budget: { type: ["integer", "null"], minimum: 1, description: "Optional positive token budget." }, max_auto_turns: { type: ["integer", "null"], minimum: 1, description: "Optional per-goal auto-continue limit." }, max_duration_seconds: { type: ["integer", "null"], minimum: 1, description: "Optional per-goal duration limit." } @@ -3138,7 +3145,7 @@ function goalToolsV2(services) { name: "update_goal_objective", description: "Edit the current OpenCode goal objective when the user explicitly asks to edit or replace it.", input: v2ObjectSchema({ - objective: { type: "string", minLength: 1, maxLength: maxObjectiveChars(), description: "The updated concrete objective." }, + objective: v2GoalTextSchema(services.maxObjectiveChars, "The updated concrete objective."), status: { type: "string", enum: ["active", "paused"], description: "Whether the edited goal should be active or paused." } }, ["objective"]), options: { codemode: false }, @@ -3155,22 +3162,12 @@ function goalToolsV2(services) { enum: ["complete", "unmet"], description: "Required. complete means achieved; unmet means blocked or impossible." }, - evidence: { - type: "string", - minLength: 1, - maxLength: maxObjectiveChars(), - description: "Required when status is complete. Summarize the concrete evidence verified." - }, - blocker: { - type: "string", - minLength: 1, - maxLength: maxObjectiveChars(), - description: "Required when status is unmet. Explain the concrete blocker or impossibility." - } + evidence: v2GoalTextSchema(services.maxObjectiveChars, "Required when status is complete. Summarize the concrete evidence verified."), + blocker: v2GoalTextSchema(services.maxObjectiveChars, "Required when status is unmet. Explain the concrete blocker or impossibility.") }, ["status"]), options: { codemode: false }, execute: async (args, toolContext) => ({ - content: await closeGoalFromTool(args, toolContext) + content: await closeGoalFromTool(args, toolContext, services) }) }, { diff --git a/src/server.ts b/src/server.ts index cac3927..cf7168a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -8,16 +8,13 @@ import { accountUsage, clearGoal, completeGoal, - configureMaxObjectiveChars, createGoal, - DEFAULT_MAX_OBJECTIVE_CHARS, estimateTokensFromText, formatGoalHistory, getAllGoals, getGoal, getGoalInternal, markGoalUnmet, - maxObjectiveChars, pauseGoalForPlanMode, PLAN_MODE_STOP_REASON, recordAssistantProgress, @@ -28,7 +25,9 @@ import { reserveContinuation, rollbackContinuationAttempt, setGoalStatus, + resolveMaxObjectiveChars, updateGoalObjective, + validateEvidence, validateObjective, } from "./state" import { compactionContext, continuationPrompt, limitPrompt, systemReminder } from "./prompts" @@ -781,13 +780,34 @@ type ToolExecContext = { type GoalServices = { options: Options + maxObjectiveChars: number isPlanAgent: (agent: unknown) => boolean initializeUsage?: (sessionID: string) => Promise } +function boundedGoalTextSchema(limit: number, description: string, validate: (value: string) => string) { + return z + .string() + .superRefine((value, ctx) => { + try { + validate(value) + } catch (error) { + ctx.addIssue({ + code: "custom", + message: error instanceof Error ? error.message : String(error), + }) + } + }) + .meta({ minLength: 1, maxLength: limit, description }) +} + +function v2GoalTextSchema(limit: number, description: string) { + return { type: "string" as const, minLength: 1, maxLength: limit, description } +} + async function createGoalFromTool(input: CreateGoalArgs, context: ToolExecContext, services: GoalServices) { const planningOnly = services.isPlanAgent(context.agent) - const objective = validateObjective(input.objective) + const objective = validateObjective(input.objective, services.maxObjectiveChars) const existing = await getGoal(context.sessionID) if (existing && !isClosedGoal(existing)) return existingGoalResult(existing, objective, planningOnly) @@ -801,6 +821,7 @@ async function createGoalFromTool(input: CreateGoalArgs, context: ToolExecContex maxNoProgressTurns: services.options.max_no_progress_turns ?? null, agent: typeof context.agent === "string" ? context.agent : null, initialStatus: planningOnly ? "paused" : "active", + maxObjectiveChars: services.maxObjectiveChars, }) } catch (error) { if (!(error instanceof Error) || !error.message.includes("non-closed goal")) throw error @@ -844,18 +865,19 @@ async function updateGoalObjectiveFromTool( const goal = await updateGoalObjective(context.sessionID, input.objective, planningOnly ? "paused" : requested, { agent: typeof context.agent === "string" ? context.agent : null, planModePause: planningOnly, + maxObjectiveChars: services.maxObjectiveChars, }) return JSON.stringify(planningOnly ? { goal, plan_mode_notice: PLAN_MODE_CREATE_NOTICE } : { goal }, null, 2) } -async function closeGoalFromTool(input: UpdateGoalArgs, context: ToolExecContext) { +async function closeGoalFromTool(input: UpdateGoalArgs, context: ToolExecContext, services: GoalServices) { if (input.status === "complete") { - const goal = await completeGoal(context.sessionID, input.evidence ?? "") + const goal = await completeGoal(context.sessionID, input.evidence ?? "", services.maxObjectiveChars) const budget = goal.tokenBudget == null ? "" : ` Token usage: ${goal.tokensUsed}/${goal.tokenBudget}.` const report = `Goal achieved. Time used: ${goal.timeUsedSeconds} seconds.${budget} Evidence: ${goal.completionEvidence}.` return JSON.stringify({ goal, completion_report: report }, null, 2) } - const goal = await markGoalUnmet(context.sessionID, input.blocker ?? "") + const goal = await markGoalUnmet(context.sessionID, input.blocker ?? "", services.maxObjectiveChars) const report = `Goal unmet. Time used: ${goal.timeUsedSeconds} seconds. Blocker: ${goal.blocker}.` return JSON.stringify({ goal, unmet_report: report }, null, 2) } @@ -929,7 +951,7 @@ const server: Plugin = async ({ client }, options?: Options) => { const maxPromptFailures = positiveIntegerOrNull(options?.max_prompt_failures) ?? DEFAULT_MAX_PROMPT_FAILURES const registerCommand = options?.register_command ?? true const commandName = commandNameFromOptions(options) - const objectiveChars = configureMaxObjectiveChars(positiveIntegerOrNull(options?.max_objective_chars) ?? DEFAULT_MAX_OBJECTIVE_CHARS) + const objectiveChars = resolveMaxObjectiveChars(options?.max_objective_chars) const taskTracker = new TaskTracker() const taskDeferredSessions = new Set() const scheduledContinuations = new Map() @@ -948,7 +970,7 @@ const server: Plugin = async ({ client }, options?: Options) => { const watchdogRescuedSessions = new Set() const planAgents = restrictedAgentSet(options) const isPlanAgent = (agent: unknown) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase()) - const goalServices: GoalServices = { options: options ?? {}, isPlanAgent } + const goalServices: GoalServices = { options: options ?? {}, isPlanAgent, maxObjectiveChars: objectiveChars } // Set by dispose so in-flight operations triggered before disposal cannot // schedule new timers or invoke continuations afterward. let disposed = false @@ -1283,7 +1305,9 @@ const server: Plugin = async ({ client }, options?: Options) => { description: "Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks. If any non-closed goal exists, this returns the existing goal as either reused or conflicting and must not be retried. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.", args: { - objective: z.string().min(1).max(objectiveChars).describe("The concrete objective to start pursuing."), + objective: boundedGoalTextSchema(objectiveChars, "The concrete objective to start pursuing.", (value) => + validateObjective(value, objectiveChars), + ), token_budget: z.number().int().positive().nullable().optional().describe("Optional positive token budget."), max_auto_turns: z.number().int().positive().nullable().optional().describe("Optional per-goal auto-continue limit."), max_duration_seconds: z.number().int().positive().nullable().optional().describe("Optional per-goal duration limit."), @@ -1296,7 +1320,11 @@ const server: Plugin = async ({ client }, options?: Options) => { description: "Set a new goal when the user explicitly asks the agent to formulate and set its own goal. The model should write the objective itself based on the user's explicit request. If any non-closed goal exists, this returns the existing goal as either reused or conflicting and must not be retried. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.", args: { - objective: z.string().min(1).max(objectiveChars).describe("The model-formulated concrete objective to start pursuing."), + objective: boundedGoalTextSchema( + objectiveChars, + "The model-formulated concrete objective to start pursuing.", + (value) => validateObjective(value, objectiveChars), + ), token_budget: z.number().int().positive().nullable().optional().describe("Optional positive token budget."), max_auto_turns: z.number().int().positive().nullable().optional().describe("Optional per-goal auto-continue limit."), max_duration_seconds: z.number().int().positive().nullable().optional().describe("Optional per-goal duration limit."), @@ -1308,7 +1336,9 @@ const server: Plugin = async ({ client }, options?: Options) => { update_goal_objective: { description: "Edit the current OpenCode goal objective when the user explicitly asks to edit or replace it.", args: { - objective: z.string().min(1).max(objectiveChars).describe("The updated concrete objective."), + objective: boundedGoalTextSchema(objectiveChars, "The updated concrete objective.", (value) => + validateObjective(value, objectiveChars), + ), status: z.enum(["active", "paused"]).optional().describe("Whether the edited goal should be active or paused."), }, async execute(args, context) { @@ -1320,21 +1350,19 @@ const server: Plugin = async ({ client }, options?: Options) => { "Close the existing goal only after an audit against real evidence. Use status complete only when the objective is achieved and no required work remains, and include evidence. Use status unmet only when the objective cannot be achieved or is blocked, and include the blocker. Do not close a goal merely because work is stopping.", args: { status: z.enum(["complete", "unmet"]).describe("Required. complete means achieved; unmet means blocked or impossible."), - evidence: z - .string() - .min(1) - .max(objectiveChars) - .optional() - .describe("Required when status is complete. Summarize the concrete evidence verified."), - blocker: z - .string() - .min(1) - .max(objectiveChars) - .optional() - .describe("Required when status is unmet. Explain the concrete blocker or impossibility."), + evidence: boundedGoalTextSchema( + objectiveChars, + "Required when status is complete. Summarize the concrete evidence verified.", + (value) => validateEvidence(value, "completion evidence", objectiveChars), + ).optional(), + blocker: boundedGoalTextSchema( + objectiveChars, + "Required when status is unmet. Explain the concrete blocker or impossibility.", + (value) => validateEvidence(value, "blocker", objectiveChars), + ).optional(), }, async execute(args, context) { - return closeGoalFromTool(args as UpdateGoalArgs, context) + return closeGoalFromTool(args as UpdateGoalArgs, context, goalServices) }, }, update_goal_status: { @@ -1559,7 +1587,7 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise() const scheduledContinuations = new Map() @@ -1579,6 +1607,7 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise() const goalServices: GoalServices = { options, + maxObjectiveChars: objectiveChars, isPlanAgent, initializeUsage: async (sessionID) => { try { @@ -2239,7 +2268,7 @@ function goalToolsV2(services: GoalServices): ToolV2Info[] { "Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks. If any non-closed goal exists, this returns the existing goal as either reused or conflicting and must not be retried. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.", input: v2ObjectSchema( { - objective: { type: "string", minLength: 1, maxLength: maxObjectiveChars(), description: "The concrete objective to start pursuing." }, + objective: v2GoalTextSchema(services.maxObjectiveChars, "The concrete objective to start pursuing."), token_budget: { type: ["integer", "null"], minimum: 1, description: "Optional positive token budget." }, max_auto_turns: { type: ["integer", "null"], minimum: 1, description: "Optional per-goal auto-continue limit." }, max_duration_seconds: { type: ["integer", "null"], minimum: 1, description: "Optional per-goal duration limit." }, @@ -2257,12 +2286,7 @@ function goalToolsV2(services: GoalServices): ToolV2Info[] { "Set a new goal when the user explicitly asks the agent to formulate and set its own goal. The model should write the objective itself based on the user's explicit request. If any non-closed goal exists, this returns the existing goal as either reused or conflicting and must not be retried. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.", input: v2ObjectSchema( { - objective: { - type: "string", - minLength: 1, - maxLength: maxObjectiveChars(), - description: "The model-formulated concrete objective to start pursuing.", - }, + objective: v2GoalTextSchema(services.maxObjectiveChars, "The model-formulated concrete objective to start pursuing."), token_budget: { type: ["integer", "null"], minimum: 1, description: "Optional positive token budget." }, max_auto_turns: { type: ["integer", "null"], minimum: 1, description: "Optional per-goal auto-continue limit." }, max_duration_seconds: { type: ["integer", "null"], minimum: 1, description: "Optional per-goal duration limit." }, @@ -2279,7 +2303,7 @@ function goalToolsV2(services: GoalServices): ToolV2Info[] { description: "Edit the current OpenCode goal objective when the user explicitly asks to edit or replace it.", input: v2ObjectSchema( { - objective: { type: "string", minLength: 1, maxLength: maxObjectiveChars(), description: "The updated concrete objective." }, + objective: v2GoalTextSchema(services.maxObjectiveChars, "The updated concrete objective."), status: { type: "string", enum: ["active", "paused"], description: "Whether the edited goal should be active or paused." }, }, ["objective"], @@ -2300,24 +2324,20 @@ function goalToolsV2(services: GoalServices): ToolV2Info[] { enum: ["complete", "unmet"], description: "Required. complete means achieved; unmet means blocked or impossible.", }, - evidence: { - type: "string", - minLength: 1, - maxLength: maxObjectiveChars(), - description: "Required when status is complete. Summarize the concrete evidence verified.", - }, - blocker: { - type: "string", - minLength: 1, - maxLength: maxObjectiveChars(), - description: "Required when status is unmet. Explain the concrete blocker or impossibility.", - }, + evidence: v2GoalTextSchema( + services.maxObjectiveChars, + "Required when status is complete. Summarize the concrete evidence verified.", + ), + blocker: v2GoalTextSchema( + services.maxObjectiveChars, + "Required when status is unmet. Explain the concrete blocker or impossibility.", + ), }, ["status"], ), options: { codemode: false }, execute: async (args, toolContext) => ({ - content: await closeGoalFromTool(args as UpdateGoalArgs, toolContext), + content: await closeGoalFromTool(args as UpdateGoalArgs, toolContext, services), }), }, { diff --git a/src/state.ts b/src/state.ts index 8be1f28..21fdb3d 100644 --- a/src/state.ts +++ b/src/state.ts @@ -39,6 +39,7 @@ export type CreateGoalOptions = { maxNoProgressTurns?: number | null agent?: string | null initialStatus?: MutableGoalStatus + maxObjectiveChars?: number | null } export type AssistantProgressInput = { @@ -402,31 +403,24 @@ async function mutate(fn: (state: State) => T | Promise) { } export const DEFAULT_MAX_OBJECTIVE_CHARS = 100_000 -let objectiveCharLimit = DEFAULT_MAX_OBJECTIVE_CHARS -export function configureMaxObjectiveChars(value: number | null | undefined) { - objectiveCharLimit = typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : DEFAULT_MAX_OBJECTIVE_CHARS - return objectiveCharLimit +export function resolveMaxObjectiveChars(value: number | null | undefined) { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : DEFAULT_MAX_OBJECTIVE_CHARS } -export function maxObjectiveChars() { - return objectiveCharLimit +function boundedText(value: string, limit: number, label: string) { + const trimmed = value.trim() + if (!trimmed) throw new Error(`${label} must not be empty`) + if ([...trimmed].length > limit) throw new Error(`${label} must be at most ${limit} characters`) + return trimmed } -export function validateObjective(objective: string) { - const value = objective.trim() - if (!value) throw new Error("goal objective must not be empty") - const limit = maxObjectiveChars() - if ([...value].length > limit) throw new Error(`goal objective must be at most ${limit} characters`) - return value +export function validateObjective(objective: string, limit = DEFAULT_MAX_OBJECTIVE_CHARS) { + return boundedText(objective, limit, "goal objective") } -export function validateEvidence(evidence: string | null | undefined, label: string) { - const value = evidence?.trim() - if (!value) throw new Error(`${label} must not be empty`) - const limit = maxObjectiveChars() - if ([...value].length > limit) throw new Error(`${label} must be at most ${limit} characters`) - return value +export function validateEvidence(evidence: string | null | undefined, label: string, limit = DEFAULT_MAX_OBJECTIVE_CHARS) { + return boundedText(evidence ?? "", limit, label) } function normalizeState(state: State): State { @@ -514,6 +508,7 @@ function normalizeCreateOptions(input?: number | null | CreateGoalOptions): Requ maxNoProgressTurns: DEFAULT_MAX_NO_PROGRESS_TURNS, agent: null, initialStatus: "active", + maxObjectiveChars: DEFAULT_MAX_OBJECTIVE_CHARS, } } return { @@ -524,6 +519,7 @@ function normalizeCreateOptions(input?: number | null | CreateGoalOptions): Requ maxNoProgressTurns: positiveIntegerOrNull(input?.maxNoProgressTurns) ?? DEFAULT_MAX_NO_PROGRESS_TURNS, agent: typeof input?.agent === "string" && input.agent.trim() ? input.agent.trim() : null, initialStatus: input?.initialStatus === "paused" ? "paused" : "active", + maxObjectiveChars: resolveMaxObjectiveChars(input?.maxObjectiveChars), } } @@ -646,8 +642,8 @@ export function getGoalSync(sessionID: string) { } export async function createGoal(sessionID: string, objective: string, options?: number | null | CreateGoalOptions) { - const value = validateObjective(objective) const normalizedOptions = normalizeCreateOptions(options) + const value = validateObjective(objective, resolveMaxObjectiveChars(normalizedOptions.maxObjectiveChars)) return mutate((state) => { const existing = state.goals[sessionID] if (existing && !isClosed(existing.status)) { @@ -702,9 +698,9 @@ export async function updateGoalObjective( sessionID: string, objective: string, status: MutableGoalStatus = "active", - options?: { agent?: string | null; planModePause?: boolean }, + options?: { agent?: string | null; planModePause?: boolean; maxObjectiveChars?: number }, ) { - const value = validateObjective(objective) + const value = validateObjective(objective, resolveMaxObjectiveChars(options?.maxObjectiveChars)) const agent = typeof options?.agent === "string" && options.agent.trim() ? options.agent.trim() : null const planModePause = options?.planModePause === true return mutate((state) => { @@ -799,7 +795,9 @@ export async function closeGoal( status: "unmet" blocker: string }, + maxObjectiveChars = DEFAULT_MAX_OBJECTIVE_CHARS, ) { + const limit = resolveMaxObjectiveChars(maxObjectiveChars) return mutate((state) => { const goal = state.goals[sessionID] if (!goal) throw new Error("cannot update goal because this session has no goal") @@ -811,12 +809,12 @@ export async function closeGoal( goal.lastAccountedAt = null goal.stopReason = input.status === "complete" ? null : "blocked" if (input.status === "complete") { - goal.completionEvidence = validateEvidence(input.evidence, "completion evidence") + goal.completionEvidence = validateEvidence(input.evidence, "completion evidence", limit) goal.blocker = null goal.lastStatus = "Goal completed." pushHistory(goal, "completed", goal.completionEvidence) } else { - goal.blocker = validateEvidence(input.blocker, "blocker") + goal.blocker = validateEvidence(input.blocker, "blocker", limit) goal.completionEvidence = null goal.lastStatus = "Goal marked unmet." pushHistory(goal, "unmet", goal.blocker) @@ -825,12 +823,12 @@ export async function closeGoal( }) } -export async function completeGoal(sessionID: string, evidence: string) { - return closeGoal(sessionID, { status: "complete", evidence }) +export async function completeGoal(sessionID: string, evidence: string, maxObjectiveChars = DEFAULT_MAX_OBJECTIVE_CHARS) { + return closeGoal(sessionID, { status: "complete", evidence }, maxObjectiveChars) } -export async function markGoalUnmet(sessionID: string, blocker: string) { - return closeGoal(sessionID, { status: "unmet", blocker }) +export async function markGoalUnmet(sessionID: string, blocker: string, maxObjectiveChars = DEFAULT_MAX_OBJECTIVE_CHARS) { + return closeGoal(sessionID, { status: "unmet", blocker }, maxObjectiveChars) } export async function clearGoal(sessionID: string) { diff --git a/test/server-v2.test.ts b/test/server-v2.test.ts index 7103ed2..550dba0 100644 --- a/test/server-v2.test.ts +++ b/test/server-v2.test.ts @@ -168,6 +168,13 @@ function goalTool(mock: MockContext, name: string) { return tool } +function v2TextMax(mock: MockContext, toolName: string, field: string) { + const input = goalTool(mock, toolName).input as { + properties?: Record + } + return input.properties?.[field]?.maxLength +} + function contentOf(result: unknown) { const value = result as { content?: string } return typeof value.content === "string" ? value.content : String(result) @@ -316,11 +323,65 @@ test("V2 setup registers the /goal command via command transform", async () => { expect(command?.template).toContain("$ARGUMENTS") expect(command?.template).toContain("call get_goal first") expect(command?.template).toContain("never call it again") + expect(command?.template).toContain("faithful representation") + expect(command?.template).toContain("do NOT compress, truncate") mock.stream.end() await cleanup() }) +test("max_objective_chars is advertised and enforced per V2 instance", async () => { + const wide = makeMockContext({ auto_continue: false, max_objective_chars: 100 }) + const narrow = makeMockContext({ auto_continue: false, max_objective_chars: 10 }) + const defaulted = makeMockContext({ auto_continue: false }) + const wideCleanup = await setupPlugin(wide as never) + const narrowCleanup = await setupPlugin(narrow as never) + const defaultCleanup = await setupPlugin(defaulted as never) + + expect(v2TextMax(wide, "create_goal", "objective")).toBe(100) + expect(v2TextMax(narrow, "create_goal", "objective")).toBe(10) + expect(v2TextMax(defaulted, "create_goal", "objective")).toBe(100_000) + expect(v2TextMax(wide, "set_goal", "objective")).toBe(100) + expect(v2TextMax(wide, "update_goal_objective", "objective")).toBe(100) + expect(v2TextMax(wide, "update_goal", "evidence")).toBe(100) + expect(v2TextMax(wide, "update_goal", "blocker")).toBe(100) + + const created = await goalTool(wide, "create_goal").execute( + { objective: "x".repeat(11) }, + toolContext("ses_wide"), + ) + expect(contentOf(created)).toContain('"status": "active"') + await expect( + goalTool(narrow, "create_goal").execute({ objective: "x".repeat(11) }, toolContext("ses_narrow")), + ).rejects.toThrow("at most 10 characters") + + const emoji = await goalTool(wide, "create_goal").execute({ objective: "😀" }, toolContext("ses_emoji")) + expect(contentOf(emoji)).toContain('"objective": "😀"') + const trimmed = await goalTool(wide, "create_goal").execute({ objective: " y " }, toolContext("ses_trim")) + expect(contentOf(trimmed)).toContain('"objective": "y"') + await expect( + goalTool(defaulted, "create_goal").execute({ objective: "x".repeat(100_001) }, toolContext("ses_default")), + ).rejects.toThrow("at most 100000 characters") + + await goalTool(wide, "create_goal").execute({ objective: "close me" }, toolContext("ses_close")) + await expect( + goalTool(wide, "update_goal").execute( + { status: "complete", evidence: "x".repeat(101) }, + toolContext("ses_close"), + ), + ).rejects.toThrow("at most 100 characters") + await expect( + goalTool(wide, "update_goal").execute({ status: "unmet", blocker: "x".repeat(101) }, toolContext("ses_close")), + ).rejects.toThrow("at most 100 characters") + + wide.stream.end() + narrow.stream.end() + defaulted.stream.end() + await wideCleanup() + await narrowCleanup() + await defaultCleanup() +}) + test("V2 setup skips command registration when register_command is false", async () => { const mock = makeMockContext({ auto_continue: false, register_command: false }) const cleanup = await setupPlugin(mock as never) diff --git a/test/server.test.ts b/test/server.test.ts index 149f75c..f389768 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -2,11 +2,10 @@ import { afterEach, beforeEach, expect, setSystemTime, test } from "bun:test" import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises" import { join } from "node:path" import { tmpdir } from "node:os" +import { z } from "zod" import plugin from "../src/server" import { accountUsage, - configureMaxObjectiveChars, - DEFAULT_MAX_OBJECTIVE_CHARS, getGoal, getGoalInternal, recordContinuationResult, @@ -18,6 +17,28 @@ function requireTool(tool: T | undefined, name: string): T { return tool } +type ToolArgs = { + args: Record + execute: (args: unknown, context: unknown) => Promise +} + +function toolArgs(tool: { args?: unknown } | undefined, name: string): ToolArgs { + const resolved = requireTool(tool, name) as ToolArgs + if (!resolved.args) throw new Error(`expected ${name} to expose args`) + return resolved +} + +function argSchema(args: ToolArgs["args"], key: string) { + const schema = args[key] + if (!schema) throw new Error(`expected args.${key}`) + return schema +} + +function advertisedMax(schema: z.ZodType) { + const json = z.toJSONSchema(schema) as { maxLength?: number } + return json.maxLength +} + async function waitFor(predicate: () => boolean) { const deadline = Date.now() + 2000 while (Date.now() < deadline) { @@ -59,7 +80,6 @@ beforeEach(async () => { afterEach(async () => { for (const dispose of serverDisposers.splice(0).reverse()) await dispose() - configureMaxObjectiveChars(DEFAULT_MAX_OBJECTIVE_CHARS) delete process.env.OPENCODE_GOAL_STATE_PATH await rm(dir, { recursive: true, force: true }) }) @@ -163,7 +183,7 @@ test("set goal lets the agent formulate the goal objective", async () => { test("create_goal reuses the same active objective without mutating state", async () => { const hooks = await setupServer( { client: { session: { promptAsync: async () => {} } } } as never, - { auto_continue: false, max_objective_chars: 4000 }, + { auto_continue: false }, ) const tools = hooks.tool! const context = { sessionID: "ses_1" } as never @@ -189,9 +209,64 @@ test("create_goal reuses the same active objective without mutating state", asyn await expect( requireTool(tools.create_goal, "create_goal").execute({ objective: " " }, context), ).rejects.toThrow("must not be empty") +}) + +test("max_objective_chars is advertised and enforced per V1 instance", async () => { + const client = { client: { session: { promptAsync: async () => {} } } } as never + const wide = await setupServer(client, { auto_continue: false, max_objective_chars: 100 }) + const narrow = await setupServer(client, { auto_continue: false, max_objective_chars: 10 }) + const defaulted = await setupServer(client, { auto_continue: false }) + const wideCreate = toolArgs(wide.tool?.create_goal, "create_goal") + const narrowCreate = toolArgs(narrow.tool?.create_goal, "create_goal") + const defaultCreate = toolArgs(defaulted.tool?.create_goal, "create_goal") + const wideUpdate = toolArgs(wide.tool?.update_goal, "update_goal") + const wideSet = toolArgs(wide.tool?.set_goal, "set_goal") + const wideEdit = toolArgs(wide.tool?.update_goal_objective, "update_goal_objective") + + const wideObjective = argSchema(wideCreate.args, "objective") + const narrowObjective = argSchema(narrowCreate.args, "objective") + expect(advertisedMax(wideObjective)).toBe(100) + expect(advertisedMax(narrowObjective)).toBe(10) + expect(advertisedMax(argSchema(defaultCreate.args, "objective"))).toBe(100_000) + expect(advertisedMax(argSchema(wideSet.args, "objective"))).toBe(100) + expect(advertisedMax(argSchema(wideEdit.args, "objective"))).toBe(100) + expect(advertisedMax(argSchema(wideUpdate.args, "evidence"))).toBe(100) + expect(advertisedMax(argSchema(wideUpdate.args, "blocker"))).toBe(100) + + expect(wideObjective.safeParse("😀").success).toBe(true) + expect(wideObjective.safeParse(" a ").success).toBe(true) + expect(wideObjective.safeParse(" ").success).toBe(false) + expect(wideObjective.safeParse("x".repeat(101)).success).toBe(false) + expect(narrowObjective.safeParse("x".repeat(11)).success).toBe(false) + + const wideContext = { sessionID: "ses_wide" } as never + const narrowContext = { sessionID: "ses_narrow" } as never + await expect(wideCreate.execute({ objective: "x".repeat(11) }, wideContext)).resolves.toContain('"status": "active"') + await expect(narrowCreate.execute({ objective: "x".repeat(11) }, narrowContext)).rejects.toThrow( + "at most 10 characters", + ) + await expect(wideCreate.execute({ objective: "😀".repeat(100) }, { sessionID: "ses_emoji" } as never)).resolves.toContain( + '"status": "active"', + ) + await expect(wideCreate.execute({ objective: " y " }, { sessionID: "ses_trim" } as never)).resolves.toContain( + '"objective": "y"', + ) await expect( - requireTool(tools.create_goal, "create_goal").execute({ objective: "x".repeat(4_001) }, context), - ).rejects.toThrow("at most 4000 characters") + defaultCreate.execute({ objective: "x".repeat(100_001) }, { sessionID: "ses_default" } as never), + ).rejects.toThrow("at most 100000 characters") + + await wideCreate.execute({ objective: "close me" }, { sessionID: "ses_close" } as never) + await expect( + wideUpdate.execute({ status: "complete", evidence: "x".repeat(101) }, { sessionID: "ses_close" } as never), + ).rejects.toThrow("at most 100 characters") + await expect( + wideUpdate.execute({ status: "unmet", blocker: "x".repeat(101) }, { sessionID: "ses_close" } as never), + ).rejects.toThrow("at most 100 characters") + const closed = await wideUpdate.execute( + { status: "complete", evidence: "x".repeat(100) }, + { sessionID: "ses_close" } as never, + ) + expect(String(closed)).toContain('"completion_report"') }) test("create_goal starts a fresh goal when the matching prior goal is closed", async () => { @@ -275,6 +350,8 @@ test("server plugin registers goal as a desktop/web command by default", async ( expect(config.command?.goal?.template).toContain("call get_goal first") expect(config.command?.goal?.template).toContain("call create_goal once") expect(config.command?.goal?.template).toContain("never call it again") + expect(config.command?.goal?.template).toContain("faithful representation") + expect(config.command?.goal?.template).toContain("do NOT compress, truncate") }) test("system transform is byte-stable across the complete goal lifecycle", async () => { diff --git a/test/state.test.ts b/test/state.test.ts index 4ba8147..c5924fa 100644 --- a/test/state.test.ts +++ b/test/state.test.ts @@ -7,6 +7,7 @@ import { clearGoal, completeGoal, createGoal, + DEFAULT_MAX_OBJECTIVE_CHARS, getAllGoals, markPendingContinuationStarted, recordAssistantProgress, @@ -22,6 +23,8 @@ import { rollbackContinuationAttempt, setGoalStatus, updateGoalObjective, + validateEvidence, + validateObjective, } from "../src/state" let dir = "" @@ -136,6 +139,27 @@ test("requires evidence when closing goals", async () => { await expect(markGoalUnmet("ses_1", "")).rejects.toThrow("blocker must not be empty") }) +test("objective and evidence limits use trimmed Unicode code points per call", async () => { + expect(validateObjective("😀", 1)).toBe("😀") + expect(validateObjective(" a ", 1)).toBe("a") + expect(() => validateObjective(" ", 1)).toThrow("must not be empty") + expect(() => validateObjective("ab", 1)).toThrow("at most 1 characters") + expect(() => validateEvidence("😀😀", "blocker", 1)).toThrow("blocker must be at most 1 characters") + expect(validateEvidence(" ok ", "completion evidence", 2)).toBe("ok") + + const created = await createGoal("ses_limit", "😀", { maxObjectiveChars: 1 }) + expect(created.objective).toBe("😀") + await expect(createGoal("ses_over", "ab", { maxObjectiveChars: 1 })).rejects.toThrow("at most 1 characters") + await expect(createGoal("ses_default", "x".repeat(DEFAULT_MAX_OBJECTIVE_CHARS + 1))).rejects.toThrow( + "at most 100000 characters", + ) + + await createGoal("ses_close", "keep") + await expect(completeGoal("ses_close", "xy", 1)).rejects.toThrow("at most 1 characters") + const completed = await completeGoal("ses_close", "😀", 1) + expect(completed.completionEvidence).toBe("😀") +}) + test("token usage marks goals budget limited", async () => { await createGoal("ses_1", "stay active", 10) const updated = await accountUsage("ses_1", 12)