diff --git a/dist/server.js b/dist/server.js index 8bf3a49..3145620 100644 --- a/dist/server.js +++ b/dist/server.js @@ -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)) @@ -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 diff --git a/scripts/smoke-v2-lifecycle.ts b/scripts/smoke-v2-lifecycle.ts index 5cb4cf9..6562e91 100644 --- a/scripts/smoke-v2-lifecycle.ts +++ b/scripts/smoke-v2-lifecycle.ts @@ -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 } | undefined await waitFor(async () => { @@ -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() diff --git a/src/server.ts b/src/server.ts index 4effa3d..b6acf9a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -236,6 +236,10 @@ function goalCommandDefinitions(commandName: string): GoalCommandDefinition[] { ] } +function omitUndefined(value: T): Partial { + return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)) as Partial +} + 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 @@ -2337,16 +2341,19 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise({ mention: _mention, ...attachment }: T) => attachment + let forwardedPrompt: Partial = {} + if (command.action === "goal") { + const stripMention = ({ 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, diff --git a/test/server-v2.test.ts b/test/server-v2.test.ts index a43c819..23a62f6 100644 --- a/test/server-v2.test.ts +++ b/test/server-v2.test.ts @@ -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({ @@ -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)