Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
]
Expand Down Expand Up @@ -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

Expand Down
139 changes: 79 additions & 60 deletions dist/server.js

Large diffs are not rendered by default.

126 changes: 83 additions & 43 deletions src/server.ts

Large diffs are not rendered by default.

50 changes: 31 additions & 19 deletions src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export type CreateGoalOptions = {
maxNoProgressTurns?: number | null
agent?: string | null
initialStatus?: MutableGoalStatus
maxObjectiveChars?: number | null
}

export type AssistantProgressInput = {
Expand Down Expand Up @@ -522,18 +523,25 @@ async function mutate<T>(fn: (state: State) => T | Promise<T>) {
})
}

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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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),
}
}

Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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")
Expand All @@ -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)
Expand All @@ -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) {
Expand Down
64 changes: 64 additions & 0 deletions test/server-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { maxLength?: number; pattern?: string }>
}
return input.properties?.[field]
}

function contentOf(result: unknown) {
const value = result as { content?: string }
return typeof value.content === "string" ? value.content : String(result)
Expand Down Expand Up @@ -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" })
Expand Down Expand Up @@ -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)
Expand Down
87 changes: 85 additions & 2 deletions test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -16,6 +17,27 @@ function requireTool<T>(tool: T | undefined, name: string): T {
return tool
}

type ToolArgs = {
args: Record<string, z.ZodType | undefined>
execute: (args: unknown, context: unknown) => Promise<unknown>
}

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) {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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"')
Expand Down
26 changes: 26 additions & 0 deletions test/state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
clearGoal,
completeGoal,
createGoal,
DEFAULT_MAX_OBJECTIVE_CHARS,
getAllGoals,
markPendingContinuationStarted,
recordAssistantProgress,
Expand All @@ -22,6 +23,8 @@ import {
rollbackContinuationAttempt,
setGoalStatus,
updateGoalObjective,
validateEvidence,
validateObjective,
} from "../src/state"

let dir = ""
Expand Down Expand Up @@ -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)
Expand Down