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
22 changes: 15 additions & 7 deletions dist/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -1353,6 +1353,9 @@ function goalCommandDefinitions(commandName) {
}
];
}
function omitUndefined(value) {
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
}
function commandNameFromOptions(options) {
const name = options?.command_name?.trim() || DEFAULT_COMMAND_NAME;
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name))
Expand Down Expand Up @@ -3256,14 +3259,19 @@ async function setupV2(context) {
cancelScheduledContinuation(input.sessionID);
clearTurnWatchdog(input.sessionID);
}
const stripMention = ({ mention: _mention, ...attachment }) => attachment;
let forwardedPrompt = {};
if (command.action === "goal") {
const stripMention = ({ mention: _mention, ...attachment }) => attachment;
const { files, agents, skills, ...promptFields } = input.prompt;
forwardedPrompt = {
...omitUndefined(promptFields),
...files ? { files: files.map(stripMention) } : {},
...agents ? { agents: agents.map(stripMention) } : {},
...skills ? { skills: skills.map(stripMention) } : {}
};
}
await context.session.prompt({
...command.action === "goal" ? {
...input.prompt,
files: input.prompt.files?.map(stripMention),
agents: input.prompt.agents?.map(stripMention),
skills: input.prompt.skills?.map(stripMention)
} : {},
...forwardedPrompt,
sessionID: input.sessionID,
text: command.template.replaceAll("$ARGUMENTS", () => input.prompt.text.trim()),
delivery: input.delivery
Expand Down
18 changes: 17 additions & 1 deletion scripts/smoke-v2-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,6 @@ try {
assert(commands.data.some((command) => command.name === "goal"))
await api(`/api/session/${sessionID}/command`, {
command: "goal", text: "Create a goal for the fixture milestone. Keep it active until the automatic continuation arrives.",
files: [], agents: [], skills: [],
})
let state: { goals: Record<string, { status: string; autoTurns: number }> } | undefined
await waitFor(async () => {
Expand All @@ -160,6 +159,23 @@ try {
})
assert.equal(state!.goals[sessionID]!.autoTurns, 2)
assert(continuationCalls > 0)
const arraysSession = await api("/api/session", {
location: { directory: project },
title: "Isolated command arrays smoke",
model: { providerID: "fixture", id: "fixture" },
agent: "build",
}) as { data: { id: string } }
await api(`/api/session/${arraysSession.data.id}/command`, {
command: "goal",
text: "Create a goal for the arrays-present command smoke.",
files: [],
agents: [],
skills: [],
})
await waitFor(async () => {
try { state = JSON.parse(await readFile(env.OPENCODE_GOAL_STATE_PATH, "utf8")) } catch { return false }
return state?.goals[arraysSession.data.id] != null
})
console.log(JSON.stringify({ result: "PASS", packagePath, sessionID, modelCalls, continuationCalls, status: state!.goals[sessionID]!.status, autoTurns: state!.goals[sessionID]!.autoTurns, artifacts: root }, null, 2))
} finally {
child.kill()
Expand Down
25 changes: 16 additions & 9 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,10 @@ function goalCommandDefinitions(commandName: string): GoalCommandDefinition[] {
]
}

function omitUndefined<T extends object>(value: T): Partial<T> {
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)) as Partial<T>
}

function commandNameFromOptions(options?: Options) {
const name = options?.command_name?.trim() || DEFAULT_COMMAND_NAME
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) return DEFAULT_COMMAND_NAME
Expand Down Expand Up @@ -2337,16 +2341,19 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise<PluginV2.Plugi
cancelScheduledContinuation(input.sessionID)
clearTurnWatchdog(input.sessionID)
}
const stripMention = <T extends { mention?: unknown }>({ mention: _mention, ...attachment }: T) => attachment
let forwardedPrompt: Partial<typeof input.prompt> = {}
if (command.action === "goal") {
const stripMention = <T extends { mention?: unknown }>({ mention: _mention, ...attachment }: T) => attachment
const { files, agents, skills, ...promptFields } = input.prompt
forwardedPrompt = {
...omitUndefined(promptFields),
...(files ? { files: files.map(stripMention) } : {}),
...(agents ? { agents: agents.map(stripMention) } : {}),
...(skills ? { skills: skills.map(stripMention) } : {}),
}
}
await context.session.prompt({
...(command.action === "goal"
? {
...input.prompt,
files: input.prompt.files?.map(stripMention),
agents: input.prompt.agents?.map(stripMention),
skills: input.prompt.skills?.map(stripMention),
}
: {}),
...forwardedPrompt,
sessionID: input.sessionID,
text: command.template.replaceAll("$ARGUMENTS", () => input.prompt.text.trim()),
delivery: input.delivery,
Expand Down
41 changes: 41 additions & 0 deletions test/server-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,9 @@ test("V2 setup registers /goal, /pause_goal, and /resume_goal via command transf

await command?.execute({ sessionID: "ses_empty", prompt: { text: "" }, delivery: "steer" })
expect(mock.promptCalls[1]).toMatchObject({ sessionID: "ses_empty", delivery: "steer" })
expect(Object.hasOwn(mock.promptCalls[1]!, "files")).toBe(false)
expect(Object.hasOwn(mock.promptCalls[1]!, "agents")).toBe(false)
expect(Object.hasOwn(mock.promptCalls[1]!, "skills")).toBe(false)
expect(mock.promptCalls[1]?.text).toContain("If the arguments are empty, call get_goal")

await pause?.execute({
Expand Down Expand Up @@ -410,6 +413,44 @@ test("V2 setup preserves existing commands and configured command-name collision
await cleanup()
})

test("V2 goal command omits undefined prompt fields and preserves empty attachment arrays", async () => {
const mock = makeMockContext({ auto_continue: false })
const cleanup = await setupPlugin(mock as never)
const command = mock.commands.find((candidate) => candidate.name === "goal")

await command?.execute({
sessionID: "ses_undefined_attachments",
prompt: {
text: "",
files: undefined,
agents: undefined,
skills: undefined,
futureOptionalField: undefined,
futureDefinedField: "kept",
} as MockPrompt & { futureOptionalField?: string; futureDefinedField?: string },
delivery: "queue",
})

expect(mock.promptCalls).toHaveLength(1)
expect(Object.hasOwn(mock.promptCalls[0]!, "files")).toBe(false)
expect(Object.hasOwn(mock.promptCalls[0]!, "agents")).toBe(false)
expect(Object.hasOwn(mock.promptCalls[0]!, "skills")).toBe(false)
expect(Object.hasOwn(mock.promptCalls[0]!, "futureOptionalField")).toBe(false)
expect((mock.promptCalls[0] as MockPrompt & { futureDefinedField?: string }).futureDefinedField).toBe("kept")

await command?.execute({
sessionID: "ses_empty_attachments",
prompt: { text: "", files: [], agents: [], skills: [] },
delivery: "queue",
})
expect(mock.promptCalls[1]?.files).toEqual([])
expect(mock.promptCalls[1]?.agents).toEqual([])
expect(mock.promptCalls[1]?.skills).toEqual([])

mock.stream.end()
await cleanup()
})

test("V2 prompt hook pauses compatibility commands before admission", async () => {
const mock = makeMockContext({ auto_continue: false }, ["goal", "pause_goal", "resume_goal"])
const cleanup = await setupPlugin(mock as never)
Expand Down
Loading