diff --git a/README.md b/README.md index b8ffffe..d52a2a8 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,10 @@ Defaults: - `command_name`: `"goal"`; renames the main goal command only. The reserved names `pause_goal` and `resume_goal` fall back to `goal` so the standalone controls remain available. - `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 Unicode code-point length of the submitted 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. Accepted values are + trimmed before persistence. Large objectives are echoed into continuation and compaction prompts. ## Goal Workflow diff --git a/dist/server.js b/dist/server.js index ae58093..fa2e155 100644 --- a/dist/server.js +++ b/dist/server.js @@ -370,21 +370,23 @@ async function mutate(fn) { })); }); } -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"); - return value; -} -function validateEvidence(evidence, label) { - const value = evidence?.trim(); - if (!value) +var DEFAULT_MAX_OBJECTIVE_CHARS = 1e5; +function resolveMaxObjectiveChars(value) { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : DEFAULT_MAX_OBJECTIVE_CHARS; +} +function boundedText(value, limit, label) { + if ([...value].length > limit) + throw new Error(`${label} must be at most ${limit} characters`); + const trimmed = value.trim(); + if (!trimmed) throw new Error(`${label} must not be empty`); - if ([...value].length > 4000) - throw new Error(`${label} must be at most 4000 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)) @@ -460,7 +462,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 { @@ -470,7 +473,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) { @@ -570,8 +574,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)) { @@ -623,7 +627,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) => { @@ -714,7 +718,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) @@ -727,12 +732,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); @@ -740,11 +745,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) => { @@ -1269,6 +1274,15 @@ function restrictedAgentSet(options) { return new Set(names.map((name) => typeof name === "string" ? name.trim().toLowerCase() : "").filter(Boolean)); } function goalCommandTemplate(commandName) { + const createGuidance = [ + "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." + ].join(" "); return `OpenCode goal mode command "/${commandName}" was invoked. Arguments: @@ -1287,7 +1301,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. +- ${createGuidance} 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.`; } @@ -1949,9 +1963,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, pattern: "\\S", description }); +} +function v2GoalTextSchema(limit, description) { + return { type: "string", minLength: 1, maxLength: limit, pattern: "\\S", 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); @@ -1964,7 +1993,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")) @@ -1994,18 +2024,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); } @@ -2069,6 +2100,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 = resolveMaxObjectiveChars(options?.max_objective_chars); const taskTracker = new TaskTracker; const taskDeferredSessions = new Set; const scheduledContinuations = new Map; @@ -2080,7 +2112,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 }; const stopStateRecoveryReporting = onStateRecovery(statePath(), async ({ stateFile, quarantineFile, outcome, error }) => { await client.app?.log?.({ body: { @@ -2382,7 +2414,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: 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.") @@ -2394,7 +2426,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: 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.") @@ -2406,7 +2438,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: 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) { @@ -2417,11 +2449,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(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: 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: { @@ -2642,6 +2674,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); + const objectiveChars = resolveMaxObjectiveChars(options.max_objective_chars); const taskTracker = new TaskTracker; const taskDeferredSessions = new Set; const scheduledContinuations = new Map; @@ -2659,6 +2692,7 @@ async function setupV2(context) { const stepTokenSums = new Map; const goalServices = { options, + maxObjectiveChars: objectiveChars, isPlanAgent, initializeUsage: async (sessionID) => { try { @@ -3327,7 +3361,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: 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." } @@ -3341,12 +3375,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: 4000, - 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." } @@ -3360,7 +3389,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: 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 }, @@ -3377,22 +3406,12 @@ function goalToolsV2(services) { enum: ["complete", "unmet"], description: "Required. complete means achieved; unmet means blocked or impossible." }, - evidence: { - type: "string", - minLength: 1, - maxLength: 4000, - description: "Required when status is complete. Summarize the concrete evidence verified." - }, - blocker: { - type: "string", - minLength: 1, - maxLength: 4000, - 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 d03735d..50b6f41 100644 --- a/src/server.ts +++ b/src/server.ts @@ -26,8 +26,10 @@ import { reserveContinuation, rollbackContinuationAttempt, setGoalStatus, + resolveMaxObjectiveChars, statePath, updateGoalObjective, + validateEvidence, validateObjective, } from "./state" import { compactionContext, continuationPrompt, limitPrompt, systemReminder } from "./prompts" @@ -47,6 +49,7 @@ type Options = { max_no_progress_turns?: number restricted_agents?: string[] allow_goal_execution_from_plan?: boolean + max_objective_chars?: number } type CreateGoalArgs = { @@ -138,6 +141,20 @@ function restrictedAgentSet(options?: Options) { } function goalCommandTemplate(commandName: string) { + const createGuidance = [ + "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.", + ].join(" ") + return `OpenCode goal mode command "/${commandName}" was invoked. Arguments: @@ -156,7 +173,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. +- ${createGuidance} 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.` } @@ -849,13 +866,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, pattern: "\\S", description }) +} + +function v2GoalTextSchema(limit: number, description: string) { + return { type: "string" as const, minLength: 1, maxLength: limit, pattern: "\\S", 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) @@ -869,6 +907,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 @@ -912,18 +951,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) } @@ -1011,6 +1051,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 = resolveMaxObjectiveChars(options?.max_objective_chars) const taskTracker = new TaskTracker() const taskDeferredSessions = new Set() const scheduledContinuations = new Map() @@ -1029,7 +1070,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 } const stopStateRecoveryReporting = onStateRecovery(statePath(), async ({ stateFile, quarantineFile, outcome, error }) => { await client.app?.log?.({ body: { @@ -1380,7 +1421,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(4000).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."), @@ -1393,7 +1436,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(4000).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."), @@ -1405,7 +1452,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(4000).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) { @@ -1417,21 +1466,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(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: 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: { @@ -1666,6 +1713,7 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise() const scheduledContinuations = new Map() @@ -1685,6 +1733,7 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise() const goalServices: GoalServices = { options, + maxObjectiveChars: objectiveChars, isPlanAgent, initializeUsage: async (sessionID) => { try { @@ -2397,7 +2446,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: 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." }, @@ -2415,12 +2464,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: 4000, - 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." }, @@ -2437,7 +2481,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: v2GoalTextSchema(services.maxObjectiveChars, "The updated concrete objective."), status: { type: "string", enum: ["active", "paused"], description: "Whether the edited goal should be active or paused." }, }, ["objective"], @@ -2458,24 +2502,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: 4000, - description: "Required when status is complete. Summarize the concrete evidence verified.", - }, - blocker: { - type: "string", - minLength: 1, - maxLength: 4000, - 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 c1cd4dd..5a403ad 100644 --- a/src/state.ts +++ b/src/state.ts @@ -40,6 +40,7 @@ export type CreateGoalOptions = { maxNoProgressTurns?: number | null agent?: string | null initialStatus?: MutableGoalStatus + maxObjectiveChars?: number | null } export type AssistantProgressInput = { @@ -522,18 +523,25 @@ async function mutate(fn: (state: State) => T | Promise) { }) } -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") - return value +export const DEFAULT_MAX_OBJECTIVE_CHARS = 100_000 + +export function resolveMaxObjectiveChars(value: number | null | undefined) { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : DEFAULT_MAX_OBJECTIVE_CHARS +} + +function boundedText(value: string, limit: number, label: string) { + if ([...value].length > limit) throw new Error(`${label} must be at most ${limit} characters`) + const trimmed = value.trim() + if (!trimmed) throw new Error(`${label} must not be empty`) + return trimmed +} + +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`) - if ([...value].length > 4000) throw new Error(`${label} must be at most 4000 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 { @@ -621,6 +629,7 @@ function normalizeCreateOptions(input?: number | null | CreateGoalOptions): Requ maxNoProgressTurns: DEFAULT_MAX_NO_PROGRESS_TURNS, agent: null, initialStatus: "active", + maxObjectiveChars: DEFAULT_MAX_OBJECTIVE_CHARS, } } return { @@ -631,6 +640,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), } } @@ -753,8 +763,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)) { @@ -809,9 +819,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) => { @@ -909,7 +919,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") @@ -921,12 +933,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) @@ -935,12 +947,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 b22d993..398031e 100644 --- a/test/server-v2.test.ts +++ b/test/server-v2.test.ts @@ -184,6 +184,13 @@ function goalTool(mock: MockContext, name: string) { return tool } +function v2TextSchema(mock: MockContext, toolName: string, field: string) { + const input = goalTool(mock, toolName).input as { + properties?: Record + } + return input.properties?.[field] +} + function contentOf(result: unknown) { const value = result as { content?: string } return typeof value.content === "string" ? value.content : String(result) @@ -356,6 +363,8 @@ test("V2 setup registers /goal, /pause_goal, and /resume_goal via command transf expect(mock.promptCalls[0]?.text).toContain("ship $& and $ARGUMENTS") expect(mock.promptCalls[0]?.text).toContain("call get_goal first") expect(mock.promptCalls[0]?.text).toContain("never call it again") + expect(mock.promptCalls[0]?.text).toContain("faithful representation") + expect(mock.promptCalls[0]?.text).toContain("do NOT compress, truncate") expect(mock.promptCalls[0]?.text.match(/\$ARGUMENTS/g)).toHaveLength(1) await command?.execute({ sessionID: "ses_empty", prompt: { text: "" }, delivery: "steer" }) @@ -508,6 +517,61 @@ test("V2 pause_goal persists the pause before prompting and ignores attachments" 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(v2TextSchema(wide, "create_goal", "objective")).toMatchObject({ maxLength: 100, pattern: "\\S" }) + expect(v2TextSchema(narrow, "create_goal", "objective")).toMatchObject({ maxLength: 10, pattern: "\\S" }) + expect(v2TextSchema(defaulted, "create_goal", "objective")).toMatchObject({ maxLength: 100_000, pattern: "\\S" }) + expect(v2TextSchema(wide, "set_goal", "objective")).toMatchObject({ maxLength: 100, pattern: "\\S" }) + expect(v2TextSchema(wide, "update_goal_objective", "objective")).toMatchObject({ maxLength: 100, pattern: "\\S" }) + expect(v2TextSchema(wide, "update_goal", "evidence")).toMatchObject({ maxLength: 100, pattern: "\\S" }) + expect(v2TextSchema(wide, "update_goal", "blocker")).toMatchObject({ maxLength: 100, pattern: "\\S" }) + + 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") + await expect( + goalTool(narrow, "create_goal").execute({ objective: " xxxxxxxxxx " }, toolContext("ses_spaced")), + ).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 9cdd360..eac3d74 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, expect, setSystemTime, spyOn, test } from "bun:t 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, @@ -16,6 +17,27 @@ 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 advertisedText(schema: z.ZodType) { + return z.toJSONSchema(schema) as { maxLength?: number; pattern?: string } +} + async function waitFor(predicate: () => boolean) { const deadline = Date.now() + 2000 while (Date.now() < deadline) { @@ -186,9 +208,68 @@ 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(advertisedText(wideObjective)).toMatchObject({ maxLength: 100, pattern: "\\S" }) + expect(advertisedText(narrowObjective)).toMatchObject({ maxLength: 10, pattern: "\\S" }) + expect(advertisedText(argSchema(defaultCreate.args, "objective"))).toMatchObject({ + maxLength: 100_000, + pattern: "\\S", + }) + expect(advertisedText(argSchema(wideSet.args, "objective"))).toMatchObject({ maxLength: 100, pattern: "\\S" }) + expect(advertisedText(argSchema(wideEdit.args, "objective"))).toMatchObject({ maxLength: 100, pattern: "\\S" }) + expect(advertisedText(argSchema(wideUpdate.args, "evidence"))).toMatchObject({ maxLength: 100, pattern: "\\S" }) + expect(advertisedText(argSchema(wideUpdate.args, "blocker"))).toMatchObject({ maxLength: 100, pattern: "\\S" }) + + 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) + expect(narrowObjective.safeParse(" xxxxxxxxxx ").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 () => { @@ -272,6 +353,8 @@ test("server plugin registers goal, pause_goal, and resume_goal as desktop/web c 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") expect(config.command?.pause_goal?.description).toBe("Pause the current long-running session goal") expect(config.command?.pause_goal?.template).toContain('command "/pause_goal" was invoked') expect(config.command?.pause_goal?.template).toContain('update_goal_status with status "paused"') diff --git a/test/state.test.ts b/test/state.test.ts index da2bbd1..4be0f83 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 = "" @@ -166,6 +169,29 @@ 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 submitted Unicode code points per call", async () => { + expect(validateObjective("😀", 1)).toBe("😀") + expect(validateObjective(" a ", 3)).toBe("a") + expect(() => validateObjective(" a ", 1)).toThrow("at most 1 characters") + expect(() => validateObjective(" ", 1)).toThrow("must not be empty") + expect(() => validateObjective(" ", 3)).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", 4)).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)