Skip to content
Open
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
4 changes: 3 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,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 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

Expand Down
128 changes: 69 additions & 59 deletions dist/server.js

Large diffs are not rendered by default.

112 changes: 69 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 @@ -39,6 +39,7 @@ export type CreateGoalOptions = {
maxNoProgressTurns?: number | null
agent?: string | null
initialStatus?: MutableGoalStatus
maxObjectiveChars?: number | null
}

export type AssistantProgressInput = {
Expand Down Expand Up @@ -401,18 +402,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) {
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, 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 @@ -500,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 {
Expand All @@ -510,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),
}
}

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

function contentOf(result: unknown) {
const value = result as { content?: string }
return typeof value.content === "string" ? value.content : String(result)
Expand Down Expand Up @@ -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)
Expand Down
84 changes: 82 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, 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,
Expand All @@ -16,6 +17,28 @@ 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 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) {
Expand Down Expand Up @@ -186,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 () => {
Expand Down Expand Up @@ -272,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 () => {
Expand Down
24 changes: 24 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 @@ -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)
Expand Down