diff --git a/crates/agent-gui/test/backend/release-notes.test.mjs b/crates/agent-gui/test/backend/release-notes.test.mjs index e7d3ffdf..628a8b71 100644 --- a/crates/agent-gui/test/backend/release-notes.test.mjs +++ b/crates/agent-gui/test/backend/release-notes.test.mjs @@ -113,13 +113,13 @@ test("AI release notes script falls back when no API key is configured", () => { } }); -test("AI release notes script calls Responses API and writes markdown", async () => { +test("AI release notes script calls Chat Completions API and writes markdown", async () => { const dir = mkdtempSync(path.join(tmpdir(), "liveagent-notes-")); let requestBody = ""; const server = http.createServer((request, response) => { assert.equal(request.method, "POST"); - assert.equal(request.url, "/v1/responses"); + assert.equal(request.url, "/v1/chat/completions"); assert.equal(request.headers.authorization, "Bearer test-key"); request.setEncoding("utf8"); @@ -131,8 +131,14 @@ test("AI release notes script calls Responses API and writes markdown", async () response.setHeader("connection", "close"); response.end( JSON.stringify({ - output_text: - "# LiveAgent v0.1.6\n\n> Release notes generated from repository context.\n\n## Overview\n\nLiveAgent now publishes cleaner release notes.", + choices: [ + { + message: { + content: + "# LiveAgent v0.1.6\n\n> Release notes generated from repository context.\n\n## Overview\n\nLiveAgent now publishes cleaner release notes.", + }, + }, + ], }), ); }); @@ -162,7 +168,8 @@ test("AI release notes script calls Responses API and writes markdown", async () const requestJson = JSON.parse(requestBody); assert.equal(requestJson.model, "gpt-test"); - assert.match(JSON.stringify(requestJson.input), /Release tag: v0\.1\.6/); + assert.equal(requestJson.reasoning_effort, undefined); + assert.match(JSON.stringify(requestJson.messages), /Release tag: v0\.1\.6/); assert.match(readFileSync(outputPath, "utf8"), /^# LiveAgent v0\.1\.6/); assert.match(readFileSync(outputPath, "utf8"), /cleaner release notes/); } finally { @@ -170,3 +177,76 @@ test("AI release notes script calls Responses API and writes markdown", async () rmSync(dir, { force: true, recursive: true }); } }); + +test("AI release notes script falls back to Responses API when chat output is empty", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "liveagent-notes-")); + const requests = []; + + const server = http.createServer((request, response) => { + assert.equal(request.method, "POST"); + assert.equal(request.headers.authorization, "Bearer test-key"); + + let requestBody = ""; + request.setEncoding("utf8"); + request.on("data", (chunk) => { + requestBody += chunk; + }); + request.on("end", () => { + requests.push({ + body: JSON.parse(requestBody), + url: request.url, + }); + + response.setHeader("content-type", "application/json"); + response.setHeader("connection", "close"); + + if (request.url === "/v1/chat/completions") { + response.end(JSON.stringify({ choices: [{ message: { content: "" } }] })); + return; + } + + assert.equal(request.url, "/v1/responses"); + response.end( + JSON.stringify({ + output_text: + "# LiveAgent v0.1.6\n\n> Release notes generated by the Responses fallback.\n\n## Overview\n\nLiveAgent still publishes AI release notes when chat output is empty.", + }), + ); + }); + }); + + try { + const address = await listen(server); + initTaggedRepo(dir, "v0.1.6"); + const outputPath = path.join(dir, "notes.md"); + const fallbackPath = path.join(dir, "fallback.md"); + writeFileSync(fallbackPath, "## What's Changed\n\n- GitHub fallback notes.\n"); + + const result = await runNotesScriptAsync(["v0.1.6", outputPath, fallbackPath], { + AI_RELEASE_NOTES_API_KEY: "test-key", + AI_RELEASE_NOTES_BASE_URL: `http://${address.address}:${address.port}/v1`, + AI_RELEASE_NOTES_MODEL: "gpt-test", + AI_RELEASE_NOTES_TIMEOUT_MS: "2000", + PACKYCODE_API_KEY: "", + OPENAI_API_KEY: "", + }, { cwd: dir }); + + assert.equal( + result.status, + 0, + `notes script failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + + assert.deepEqual(requests.map((request) => request.url), [ + "/v1/chat/completions", + "/v1/responses", + ]); + assert.equal(requests[0].body.reasoning_effort, undefined); + assert.equal(requests[1].body.reasoning, undefined); + assert.match(readFileSync(outputPath, "utf8"), /^# LiveAgent v0\.1\.6/); + assert.match(readFileSync(outputPath, "utf8"), /Responses fallback/); + } finally { + await close(server); + rmSync(dir, { force: true, recursive: true }); + } +}); diff --git a/scripts/release/create-ai-release-notes.mjs b/scripts/release/create-ai-release-notes.mjs index a7ddbe77..9c6bc8ca 100644 --- a/scripts/release/create-ai-release-notes.mjs +++ b/scripts/release/create-ai-release-notes.mjs @@ -7,6 +7,7 @@ import { parseReleaseVersion } from "./release-version.mjs"; const DEFAULT_BASE_URL = "https://codex-api.packycode.com/v1"; const DEFAULT_MODEL = "gpt-5.5"; +const DEFAULT_REASONING_EFFORT = ""; const MAX_CONTEXT_CHARS = 22000; const [releaseTagArg, outputPath, fallbackNotesPath] = process.argv.slice(2); @@ -204,11 +205,17 @@ function responseText(payload) { return ""; } -async function createResponse({ apiKey, baseUrl, model, prompt }) { - const endpoint = `${baseUrl.replace(/\/+$/, "")}/responses`; - const timeoutMs = Number.parseInt(process.env.AI_RELEASE_NOTES_TIMEOUT_MS ?? "60000", 10); +function normalizeReasoningEffort(value) { + const effort = value.trim().toLowerCase(); + if (!effort || effort === "none" || effort === "off" || effort === "false" || effort === "xhigh") { + return ""; + } + return effort; +} + +async function fetchJsonWithTimeout(endpoint, { apiKey, body, timeoutMs }) { const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), Number.isFinite(timeoutMs) ? timeoutMs : 60000); + const timeout = setTimeout(() => controller.abort(), timeoutMs); const response = await fetch(endpoint, { method: "POST", headers: { @@ -216,34 +223,75 @@ async function createResponse({ apiKey, baseUrl, model, prompt }) { "Content-Type": "application/json", }, signal: controller.signal, - body: JSON.stringify({ - input: [ - { - role: "system", - content: [ - { - type: "input_text", - text: "You are a precise release-notes editor. You never make claims that are not grounded in the provided repository context.", - }, - ], - }, - { - role: "user", - content: [{ type: "input_text", text: prompt }], - }, - ], - max_output_tokens: 3000, - model, - }), + body: JSON.stringify(body), }).finally(() => clearTimeout(timeout)); const text = await response.text(); if (!response.ok) { - throw new Error(`Responses API returned HTTP ${response.status}: ${text.slice(0, 500)}`); + throw new Error(`API returned HTTP ${response.status}: ${text.slice(0, 500)}`); } return JSON.parse(text); } +async function createResponse({ apiKey, baseUrl, model, prompt, reasoningEffort, timeoutMs }) { + const endpoint = `${baseUrl.replace(/\/+$/, "")}/responses`; + const body = { + input: [ + { + role: "system", + content: [ + { + type: "input_text", + text: "You are a precise release-notes editor. You never make claims that are not grounded in the provided repository context.", + }, + ], + }, + { + role: "user", + content: [{ type: "input_text", text: prompt }], + }, + ], + max_output_tokens: 3000, + model, + store: false, + }; + if (reasoningEffort) { + body.reasoning = { effort: reasoningEffort }; + } + return fetchJsonWithTimeout(endpoint, { + apiKey, + timeoutMs, + body, + }); +} + +async function createChatCompletion({ apiKey, baseUrl, model, prompt, reasoningEffort, timeoutMs }) { + const endpoint = `${baseUrl.replace(/\/+$/, "")}/chat/completions`; + const body = { + max_tokens: 3000, + messages: [ + { + role: "system", + content: + "You are a precise release-notes editor. You never make claims that are not grounded in the provided repository context.", + }, + { + role: "user", + content: prompt, + }, + ], + model, + }; + if (reasoningEffort) { + body.reasoning_effort = reasoningEffort; + } + return fetchJsonWithTimeout(endpoint, { + apiKey, + timeoutMs, + body, + }); +} + async function main() { const apiKey = process.env.AI_RELEASE_NOTES_API_KEY?.trim() || @@ -256,12 +304,49 @@ async function main() { const baseUrl = process.env.AI_RELEASE_NOTES_BASE_URL?.trim() || DEFAULT_BASE_URL; const model = process.env.AI_RELEASE_NOTES_MODEL?.trim() || DEFAULT_MODEL; + const reasoningEffort = normalizeReasoningEffort( + process.env.AI_RELEASE_NOTES_REASONING_EFFORT ?? DEFAULT_REASONING_EFFORT, + ); + const parsedTimeoutMs = Number.parseInt(process.env.AI_RELEASE_NOTES_TIMEOUT_MS ?? "60000", 10); + const timeoutMs = Number.isFinite(parsedTimeoutMs) ? parsedTimeoutMs : 60000; try { const context = collectContext(); const prompt = buildPrompt(context); - const payload = await createResponse({ apiKey, baseUrl, model, prompt }); - const markdown = normalizeMarkdown(responseText(payload)); + let markdown = ""; + let chatCompleted = false; + try { + const chatPayload = await createChatCompletion({ + apiKey, + baseUrl, + model, + prompt, + reasoningEffort, + timeoutMs, + }); + chatCompleted = true; + markdown = normalizeMarkdown(responseText(chatPayload)); + } catch (error) { + console.warn( + `Chat completions unavailable: ${ + error instanceof Error ? error.message : String(error) + }; trying Responses API fallback.`, + ); + } + if (!markdown) { + if (chatCompleted) { + console.warn("Chat completions returned empty release notes; trying Responses API fallback."); + } + const responsesPayload = await createResponse({ + apiKey, + baseUrl, + model, + prompt, + reasoningEffort, + timeoutMs, + }); + markdown = normalizeMarkdown(responseText(responsesPayload)); + } if (!markdown) { writeFallback("model returned empty release notes"); return;