refactor: move git generation to session structured output

- Use active session prompts for commit/PR generation and drop backend git generation routes.
- Improve tool activity UX with Structured Output rendering, description styling tweaks, and detailed-mode expansion fixes.
This commit is contained in:
Bohdan Triapitsyn
2026-02-24 15:37:35 +02:00
parent 9462ac8498
commit 4a8c69e36a
11 changed files with 455 additions and 511 deletions
-447
View File
@@ -604,9 +604,6 @@ let validatedZenFallback = null;
let cachedZenModels = null;
let cachedZenModelsTimestamp = 0;
const ZEN_MODELS_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
let cachedGitModelCatalog = null;
let cachedGitModelCatalogTimestamp = 0;
const GIT_MODEL_CATALOG_CACHE_TTL = 30 * 1000;
/**
* Fetch free models from the zen API with caching. Returns an array of
@@ -663,219 +660,6 @@ const resolveZenModel = async (override) => {
return validatedZenFallback || ZEN_DEFAULT_MODEL;
};
const getGitModelCatalog = async () => {
const now = Date.now();
if (cachedGitModelCatalog && now - cachedGitModelCatalogTimestamp < GIT_MODEL_CATALOG_CACHE_TTL) {
return cachedGitModelCatalog;
}
const response = await fetch(buildOpenCodeUrl('/model', ''), {
method: 'GET',
headers: {
Accept: 'application/json',
...getOpenCodeAuthHeaders(),
},
signal: AbortSignal.timeout(8_000),
});
if (!response.ok) {
throw new Error(`Failed to fetch model catalog: ${response.status}`);
}
const payload = await response.json().catch(() => null);
const modelRefs = new Set();
if (Array.isArray(payload)) {
for (const item of payload) {
if (!item || typeof item !== 'object') {
continue;
}
const providerID = typeof item.providerID === 'string' ? item.providerID.trim() : '';
const modelID = typeof item.modelID === 'string' ? item.modelID.trim() : '';
if (providerID && modelID) {
modelRefs.add(`${providerID}/${modelID}`);
}
}
}
cachedGitModelCatalog = modelRefs;
cachedGitModelCatalogTimestamp = now;
return modelRefs;
};
/**
* Resolve git generation model based on priority:
* 1) request providerId+modelId
* 2) saved settings gitProviderId+gitModelId
* 3) legacy zenModel from request/settings as zen/<model>
* 4) Zen default (validatedZenFallback || ZEN_DEFAULT_MODEL)
*/
const resolveGitModel = async (requestParams) => {
const { providerId, modelId, zenModel } = requestParams || {};
const requestProviderId = typeof providerId === 'string' ? providerId.trim() : '';
const requestModelId = typeof modelId === 'string' ? modelId.trim() : '';
let modelCatalog = null;
try {
modelCatalog = await getGitModelCatalog();
} catch {
modelCatalog = null;
}
const hasModel = (providerID, modelID) => {
if (!modelCatalog) {
return false;
}
return modelCatalog.has(`${providerID}/${modelID}`);
};
if (requestProviderId && requestModelId && hasModel(requestProviderId, requestModelId)) {
return { providerID: requestProviderId, modelID: requestModelId };
}
try {
const settings = await readSettingsFromDisk();
const settingsProviderId = typeof settings?.gitProviderId === 'string' ? settings.gitProviderId.trim() : '';
const settingsModelId = typeof settings?.gitModelId === 'string' ? settings.gitModelId.trim() : '';
if (settingsProviderId && settingsModelId && hasModel(settingsProviderId, settingsModelId)) {
return { providerID: settingsProviderId, modelID: settingsModelId };
}
} catch {
// ignore
}
const fallbackZenModel = typeof zenModel === 'string' && zenModel.trim().length > 0
? zenModel.trim()
: (await resolveZenModel(zenModel));
return { providerID: 'zen', modelID: fallbackZenModel };
};
const GIT_GENERATION_TIMEOUT_MS = 2 * 60 * 1000;
const GIT_GENERATION_POLL_INTERVAL_MS = 500;
/**
* Generate text using OpenCode session flow:
* - Create short-lived session
* - POST prompt_async with model and text prompt
* - Poll session messages until final assistant response
* - Extract text from parts
* - Best-effort cleanup of temporary session
*/
const generateWithSessionFlow = async ({ prompt, providerID, modelID }) => {
const completionTimeout = createTimeoutSignal(GIT_GENERATION_TIMEOUT_MS);
let sessionId = null;
try {
const createUrl = buildOpenCodeUrl('/session', '');
const createResponse = await fetch(createUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...getOpenCodeAuthHeaders(),
},
body: JSON.stringify({
title: 'Git Generation',
}),
signal: completionTimeout.signal,
});
if (!createResponse.ok) {
const errorBody = await createResponse.json().catch(() => ({}));
throw new Error(`Failed to create session: ${createResponse.status} ${JSON.stringify(errorBody)}`);
}
const sessionData = await createResponse.json();
sessionId = sessionData?.id;
if (!sessionId) {
throw new Error('Session created but no ID returned');
}
const promptUrl = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}/prompt_async`, '');
const promptResponse = await fetch(promptUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...getOpenCodeAuthHeaders(),
},
body: JSON.stringify({
model: { providerID, modelID },
parts: [{ type: 'text', text: prompt }],
}),
signal: completionTimeout.signal,
});
if (!promptResponse.ok) {
const errorBody = await promptResponse.json().catch(() => ({}));
throw new Error(`Failed to send prompt: ${promptResponse.status} ${JSON.stringify(errorBody)}`);
}
const messagesUrl = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}/message`, '');
let lastAssistantText = '';
let pollingAttempts = 0;
const maxPollingAttempts = Math.ceil(GIT_GENERATION_TIMEOUT_MS / GIT_GENERATION_POLL_INTERVAL_MS);
while (pollingAttempts < maxPollingAttempts) {
pollingAttempts++;
await new Promise((resolve) => setTimeout(resolve, GIT_GENERATION_POLL_INTERVAL_MS));
const messagesResponse = await fetch(`${messagesUrl}?limit=10`, {
method: 'GET',
headers: {
Accept: 'application/json',
...getOpenCodeAuthHeaders(),
},
signal: completionTimeout.signal,
});
if (!messagesResponse.ok) {
console.warn(`Session messages poll failed: ${messagesResponse.status}`);
continue;
}
const messages = await messagesResponse.json().catch(() => null);
if (!Array.isArray(messages)) {
continue;
}
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg?.info?.role === 'assistant' && msg?.info?.finish === 'stop') {
if (Array.isArray(msg.parts)) {
const textParts = msg.parts
.filter((p) => p?.type === 'text' && typeof p?.text === 'string')
.map((p) => p.text)
.filter(Boolean);
if (textParts.length > 0) {
return textParts.join('\n').trim();
}
}
}
}
}
throw new Error('Timeout waiting for generation to complete');
} finally {
completionTimeout.cleanup();
if (sessionId) {
try {
const deleteUrl = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}`, '');
await fetch(deleteUrl, {
method: 'DELETE',
headers: getOpenCodeAuthHeaders(),
signal: AbortSignal.timeout(5000),
}).catch((err) => {
console.warn('Failed to cleanup temporary session:', err?.message || err);
});
} catch (err) {
console.warn('Failed to cleanup temporary session:', err?.message || err);
}
}
}
};
const summarizeText = async (text, targetLength, zenModel) => {
if (!text || typeof text !== 'string' || text.trim().length === 0) return text;
@@ -1215,53 +999,6 @@ const buildTemplateVariables = async (payload, sessionId) => {
};
};
const stripJsonMarkdownWrapper = (value) => {
if (typeof value !== 'string') {
return '';
}
let trimmed = value.trim();
if (!trimmed) {
return '';
}
if (trimmed.startsWith('```')) {
trimmed = trimmed.replace(/^```(?:json)?\s*/i, '');
const closingFenceIndex = trimmed.lastIndexOf('```');
if (closingFenceIndex !== -1) {
trimmed = trimmed.slice(0, closingFenceIndex);
}
trimmed = trimmed.trim();
}
if (trimmed.endsWith('```')) {
trimmed = trimmed.slice(0, -3).trim();
}
return trimmed;
};
const extractJsonObject = (value) => {
if (typeof value !== 'string') {
return null;
}
const source = value.trim();
if (!source) {
return null;
}
let start = source.indexOf('{');
while (start !== -1) {
let end = source.indexOf('}', start + 1);
while (end !== -1) {
const candidate = source.slice(start, end + 1);
try {
JSON.parse(candidate);
return candidate;
} catch {
end = source.indexOf('}', end + 1);
}
}
start = source.indexOf('{', start + 1);
}
return null;
};
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
: path.join(os.homedir(), '.config', 'openchamber');
@@ -9884,190 +9621,6 @@ async function main(options = {}) {
}
});
app.post('/api/git/commit-message', async (req, res) => {
const { collectDiffs } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory || typeof directory !== 'string') {
return res.status(400).json({ error: 'directory parameter is required' });
}
const files = Array.isArray(req.body?.files) ? req.body.files : [];
if (files.length === 0) {
return res.status(400).json({ error: 'At least one file is required' });
}
const diffs = await collectDiffs(directory, files);
if (diffs.length === 0) {
return res.status(400).json({ error: 'No diffs available for selected files' });
}
const MAX_DIFF_LENGTH = 4000;
const diffSummaries = diffs
.map(({ path, diff }) => {
const trimmed = diff.length > MAX_DIFF_LENGTH ? `${diff.slice(0, MAX_DIFF_LENGTH)}\n...` : diff;
return `FILE: ${path}\n${trimmed}`;
})
.join('\n\n');
const prompt = `You are generating a Conventional Commits subject line from the provided diff.
Return EXACTLY one JSON object (no code fences, no extra keys, no extra text):
{"subject": string, "highlights": string[]}
Non-negotiable:
- Output must be valid JSON (double quotes).
- Only claim what is supported by the diff. If unsure, be more general; do not guess.
subject:
- Format: <type>: <summary> (NO scope; never write type(scope))
- Allowed types: feat, fix, refactor, perf, docs, test, build, ci, chore, style, revert
- Choose type (prefer fix when ambiguous):
- fix: any bug/regression/wrong behavior (state, selection, navigation, persistence, crash)
- feat: new user-facing capability or new workflow (not just guardrails/defaults)
- refactor/perf/docs/test/build/ci/style/chore/revert: only when clearly the primary change
- Summary style:
- imperative, present tense, outcome-first
- <= 72 characters, no trailing period
- avoid filenames, internal function names, and implementation details
highlights:
- 0-3 items; it is OK to return [].
- Each item: one plain sentence, <= 90 chars, starts with an Uppercase verb.
- Must add information not already in the subject.
- Prefer user-observable behaviors (UI flow, navigation, selection, default view, persistence).
- No markdown bullets, no file paths, no helper names.
Diff summary (may be truncated):
${diffSummaries}`;
const { providerID, modelID } = await resolveGitModel({
providerId: req.body?.providerId,
modelId: req.body?.modelId,
zenModel: req.body?.zenModel,
});
const raw = await generateWithSessionFlow({ prompt, providerID, modelID });
if (!raw) {
return res.status(502).json({ error: 'No commit message returned by generator' });
}
const cleanedJson = stripJsonMarkdownWrapper(raw);
const extractedJson = extractJsonObject(cleanedJson) || extractJsonObject(raw);
const candidates = [cleanedJson, extractedJson, raw].filter((candidate, index, array) => {
return candidate && array.indexOf(candidate) === index;
});
for (const candidate of candidates) {
if (!(candidate.startsWith('{') || candidate.startsWith('['))) {
continue;
}
try {
const parsed = JSON.parse(candidate);
return res.json({ message: parsed });
} catch (parseError) {
console.warn('Commit message generation returned non-JSON body:', parseError);
}
}
res.json({ message: { subject: raw, highlights: [] } });
} catch (error) {
console.error('Failed to generate commit message:', error);
res.status(500).json({ error: error.message || 'Failed to generate commit message' });
}
});
app.post('/api/git/pr-description', async (req, res) => {
const { getRangeDiff, getRangeFiles } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory || typeof directory !== 'string') {
return res.status(400).json({ error: 'directory parameter is required' });
}
const base = typeof req.body?.base === 'string' ? req.body.base.trim() : '';
const head = typeof req.body?.head === 'string' ? req.body.head.trim() : '';
if (!base || !head) {
return res.status(400).json({ error: 'base and head are required' });
}
const filesToDiff = await getRangeFiles(directory, { base, head });
const diffs = [];
for (const filePath of filesToDiff) {
const diff = await getRangeDiff(directory, { base, head, path: filePath, contextLines: 3 }).catch(() => '');
if (diff && diff.trim().length > 0) {
diffs.push({ path: filePath, diff });
}
}
if (diffs.length === 0) {
return res.status(400).json({ error: 'No diffs available for base...head' });
}
const diffSummaries = diffs.map(({ path, diff }) => `FILE: ${path}\n${diff}`).join('\n\n');
const context = typeof req.body?.context === 'string' ? req.body.context.trim() : '';
let prompt = `You are drafting a GitHub Pull Request title + description for a squash-merge workflow.
Respond in JSON of the shape {"title": string, "body": string} (ONLY JSON in response, no markdown fences) with these rules:
- Title format: conventional, outcome-first, <= 90 chars, no trailing punctuation.
- Use: <type>(<scope>): <summary>. Types: feat, fix, refactor, perf, docs, test, chore.
- Pick the most important user-facing outcome first; include a second major outcome only when needed.
- Body: GitHub-flavored markdown with sections in this exact order: ## Summary, ## Why, ## Testing.
- Summary: 3-6 bullets, concrete product/workflow impact, no vague filler, no internal helper names.
- Why: 1-3 bullets explaining motivation/tradeoff (what problem this solves for users/devs).
- Testing: checkbox list using "- [ ]"; include realistic manual/automated checks inferred from the diff.
- If tests were not run, include "- [ ] Not run locally" as first testing item.
- Keep language crisp and specific; avoid generic boilerplate.
Context:
- base branch: ${base}
- head branch: ${head}`;
if (context) {
prompt += `\n\nAdditional context provided by user:\n${context}`;
}
prompt += `\n\nDiff summary:\n${diffSummaries}`;
const { providerID, modelID } = await resolveGitModel({
providerId: req.body?.providerId,
modelId: req.body?.modelId,
zenModel: req.body?.zenModel,
});
const raw = await generateWithSessionFlow({ prompt, providerID, modelID });
if (!raw) {
return res.status(502).json({ error: 'No PR description returned by generator' });
}
const cleanedJson = stripJsonMarkdownWrapper(raw);
const extractedJson = extractJsonObject(cleanedJson) || extractJsonObject(raw);
const candidates = [cleanedJson, extractedJson, raw].filter((candidate, index, array) => {
return candidate && array.indexOf(candidate) === index;
});
for (const candidate of candidates) {
if (!(candidate.startsWith('{') || candidate.startsWith('['))) {
continue;
}
try {
const parsed = JSON.parse(candidate);
const title = typeof parsed?.title === 'string' ? parsed.title : '';
const body = typeof parsed?.body === 'string' ? parsed.body : '';
return res.json({ title, body });
} catch (parseError) {
console.warn('PR description generation returned non-JSON body:', parseError);
}
}
return res.json({ title: '', body: raw });
} catch (error) {
console.error('Failed to generate PR description:', error);
return res.status(500).json({ error: error.message || 'Failed to generate PR description' });
}
});
app.post('/api/git/pull', async (req, res) => {
const { pull } = await getGitLibraries();
try {