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
+323 -7
View File
@@ -2,6 +2,10 @@
import type { RuntimeAPIs } from './api/types';
import * as gitHttp from './gitApiHttp';
import { opencodeClient } from './opencode/client';
import { useSessionStore } from '@/stores/useSessionStore';
import { useContextStore } from '@/stores/contextStore';
import { useConfigStore } from '@/stores/useConfigStore';
export type {
GitStatus,
@@ -107,22 +111,334 @@ export async function generateCommitMessage(
files: string[],
options?: { zenModel?: string; providerId?: string; modelId?: string }
): Promise<{ message: import('./api/types').GeneratedCommitMessage }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.generateCommitMessage(directory, files, options);
return gitHttp.generateCommitMessage(directory, files, options);
const startedAt = Date.now();
void options;
const generationSession = resolveSessionGenerationContext();
if (!generationSession) {
throw new Error('Select an active session for generation');
}
console.info('[git-generation][browser] request', {
transport: 'session',
kind: 'commit',
directory,
selectedFiles: files.length,
sessionId: generationSession.sessionId,
providerId: generationSession.providerID,
modelId: generationSession.modelID,
agent: generationSession.agent,
variant: generationSession.variant,
});
const prompt = `You are generating a Conventional Commits subject line using session context and selected file paths.
Return JSON with exactly this shape:
{"subject": string, "highlights": string[]}
Rules:
- subject format: <type>: <summary>
- allowed types: feat, fix, refactor, perf, docs, test, build, ci, chore, style, revert
- no scope in subject
- keep subject concise and user-facing
- highlights: 0-3 concise user-facing points
Selected files:
${files.map((file) => `- ${file}`).join('\n')}`;
try {
const structured = await runStructuredGenerationInActiveSession({
directory,
prompt,
generationSession,
schema: {
type: 'object',
additionalProperties: false,
properties: {
subject: { type: 'string', description: 'Conventional commit subject line.' },
highlights: {
type: 'array',
items: { type: 'string', description: 'Short user-facing highlight.' },
maxItems: 3,
description: 'Optional short user-facing highlights.',
},
},
required: ['subject', 'highlights'],
},
kind: 'commit',
});
const subject = typeof structured.subject === 'string' ? structured.subject.trim() : '';
const highlights = Array.isArray(structured.highlights)
? structured.highlights.filter((item) => typeof item === 'string').map((item) => item.trim()).filter(Boolean).slice(0, 3)
: [];
if (!subject) {
throw new Error('Structured output missing subject');
}
const result = { message: { subject, highlights } };
console.info('[git-generation][browser] success', {
transport: 'session',
kind: 'commit',
elapsedMs: Date.now() - startedAt,
subjectLength: result.message.subject.length,
highlightsCount: result.message.highlights.length,
});
return result;
} catch (error) {
console.error('[git-generation][browser] failed', {
transport: 'session',
kind: 'commit',
elapsedMs: Date.now() - startedAt,
message: error instanceof Error ? error.message : String(error),
error,
});
throw error;
}
}
export async function generatePullRequestDescription(
directory: string,
payload: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string }
): Promise<import('./api/types').GeneratedPullRequestDescription> {
const runtime = getRuntimeGit();
if (runtime?.generatePullRequestDescription) {
return runtime.generatePullRequestDescription(directory, payload);
const startedAt = Date.now();
const generationSession = resolveSessionGenerationContext();
if (!generationSession) {
throw new Error('Select an active session for generation');
}
const commitLog = await getGitLog(directory, {
from: payload.base,
to: payload.head,
maxCount: 50,
});
const commits = (Array.isArray(commitLog?.all) ? commitLog.all : [])
.filter((entry) => typeof entry?.hash === 'string' && entry.hash.length > 0)
.map((entry) => ({
hash: entry.hash,
subject: typeof entry.message === 'string' ? entry.message.trim() : '',
}));
if (commits.length === 0) {
throw new Error(`No commits found in range ${payload.base}...${payload.head}`);
}
const filesSet = new Set<string>();
await Promise.all(commits.map(async (commit) => {
try {
const response = await getCommitFiles(directory, commit.hash);
const files = Array.isArray(response?.files) ? response.files : [];
for (const file of files) {
if (typeof file?.path === 'string' && file.path.trim().length > 0) {
filesSet.add(file.path.trim());
}
}
} catch (error) {
console.warn('[git-generation][browser] failed to collect commit files', {
hash: commit.hash,
error: error instanceof Error ? error.message : String(error),
});
}
}));
const changedFiles = Array.from(filesSet).sort().slice(0, 300);
console.info('[git-generation][browser] request', {
transport: 'session',
kind: 'pr',
directory,
sessionId: generationSession.sessionId,
providerId: generationSession.providerID,
modelId: generationSession.modelID,
agent: generationSession.agent,
variant: generationSession.variant,
base: payload.base,
head: payload.head,
commits: commits.length,
changedFiles: changedFiles.length,
});
const prompt = `You are drafting GitHub Pull Request title and body using session context, commit list, and changed files.
Return JSON with exactly this shape:
{"title": string, "body": string}
Rules:
- title: concise, outcome-first, conventional style
- body: markdown with sections: ## Summary, ## Why, ## Testing
- keep output concrete and user-facing
Base branch: ${payload.base}
Head branch: ${payload.head}
Commits in range (base...head):
${commits.map((commit) => `- ${commit.hash.slice(0, 7)} ${commit.subject || '(no subject)'}`).join('\n')}
Files changed across these commits:
${changedFiles.length > 0 ? changedFiles.map((file) => `- ${file}`).join('\n') : '- none detected'}
${payload.context?.trim() ? `\nAdditional context:\n${payload.context.trim()}` : ''}`;
try {
const structured = await runStructuredGenerationInActiveSession({
directory,
prompt,
generationSession,
schema: {
type: 'object',
additionalProperties: false,
properties: {
title: { type: 'string', description: 'Pull request title.' },
body: { type: 'string', description: 'Pull request markdown description.' },
},
required: ['title', 'body'],
},
kind: 'pr',
});
const result = {
title: typeof structured.title === 'string' ? structured.title.trim() : '',
body: typeof structured.body === 'string' ? structured.body.trim() : '',
};
console.info('[git-generation][browser] success', {
transport: 'session',
kind: 'pr',
elapsedMs: Date.now() - startedAt,
titleLength: result.title.length,
bodyLength: result.body.length,
});
return result;
} catch (error) {
console.error('[git-generation][browser] failed', {
transport: 'session',
kind: 'pr',
elapsedMs: Date.now() - startedAt,
message: error instanceof Error ? error.message : String(error),
error,
});
throw error;
}
return gitHttp.generatePullRequestDescription(directory, payload);
}
type SessionGenerationContext = {
sessionId: string;
providerID: string;
modelID: string;
agent?: string;
variant?: string;
};
const resolveSessionGenerationContext = (): SessionGenerationContext | null => {
const sessionId = useSessionStore.getState().currentSessionId;
if (!sessionId) {
return null;
}
const context = useContextStore.getState();
const config = useConfigStore.getState();
const agent = context.getSessionAgentSelection(sessionId) || config.currentAgentName || undefined;
const sessionModel = context.getSessionModelSelection(sessionId);
const agentModel = agent ? context.getAgentModelForSession(sessionId, agent) : null;
const selectedModel = agentModel || sessionModel || (config.currentProviderId && config.currentModelId
? { providerId: config.currentProviderId, modelId: config.currentModelId }
: null);
if (!selectedModel?.providerId || !selectedModel?.modelId) {
return null;
}
const variant = agent
? context.getAgentModelVariantForSession(sessionId, agent, selectedModel.providerId, selectedModel.modelId)
: (config.currentVariant || undefined);
return {
sessionId,
providerID: selectedModel.providerId,
modelID: selectedModel.modelId,
agent,
variant,
};
};
const runStructuredGenerationInActiveSession = async ({
directory,
prompt,
generationSession,
schema,
kind,
}: {
directory: string;
prompt: string;
generationSession: SessionGenerationContext;
schema: Record<string, unknown>;
kind: 'commit' | 'pr';
}): Promise<Record<string, unknown>> => {
const requestStartedAt = Date.now();
console.info('[git-generation][browser] runStructuredGenerationInActiveSession start', {
kind,
directory,
sessionId: generationSession.sessionId,
providerID: generationSession.providerID,
modelID: generationSession.modelID,
agent: generationSession.agent,
variant: generationSession.variant,
});
const trimmedDirectory = typeof directory === 'string' ? directory.trim() : '';
const firstNewlineIndex = prompt.indexOf('\n');
const visiblePrompt = (firstNewlineIndex === -1 ? prompt : prompt.slice(0, firstNewlineIndex)).trim();
const hiddenPrompt = (firstNewlineIndex === -1 ? '' : prompt.slice(firstNewlineIndex + 1)).trim();
const promptParts: Array<{ type: 'text'; text: string; synthetic?: boolean }> = [];
if (visiblePrompt) {
promptParts.push({ type: 'text', text: visiblePrompt, synthetic: false });
}
if (hiddenPrompt) {
promptParts.push({ type: 'text', text: hiddenPrompt, synthetic: true });
}
if (promptParts.length === 0) {
promptParts.push({ type: 'text', text: prompt, synthetic: false });
}
const response = await opencodeClient.withDirectory(directory, async () => {
return opencodeClient.getApiClient().session.prompt({
sessionID: generationSession.sessionId,
...(trimmedDirectory.length > 0 ? { directory: trimmedDirectory } : {}),
model: {
providerID: generationSession.providerID,
modelID: generationSession.modelID,
},
...(generationSession.agent ? { agent: generationSession.agent } : {}),
...(generationSession.variant ? { variant: generationSession.variant } : {}),
format: {
type: 'json_schema',
schema,
retryCount: 2,
},
parts: promptParts,
});
});
const responseError = response?.error as { message?: string } | undefined;
if (!response?.data) {
throw new Error(responseError?.message || `Failed to generate ${kind} output`);
}
const info = response.data.info as { finish?: string; structured_output?: unknown; structured?: unknown; error?: unknown };
const structuredOutput = info?.structured_output || info?.structured;
if (!structuredOutput || typeof structuredOutput !== 'object' || Array.isArray(structuredOutput)) {
console.error('[git-generation][browser] invalid structured output', {
kind,
sessionId: generationSession.sessionId,
elapsedMs: Date.now() - requestStartedAt,
finish: info?.finish,
messageInfo: response.data.info,
messageParts: response.data.parts,
});
throw new Error('No structured output returned by session');
}
return structuredOutput as Record<string, unknown>;
};
export async function listGitWorktrees(directory: string): Promise<import('./api/types').GitWorktreeInfo[]> {
const runtime = getRuntimeGit();
if (runtime?.worktree?.list) {
+9 -1
View File
@@ -235,7 +235,15 @@ export async function generateCommitMessage(
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to generate commit message');
console.error('[git-generation][browser] http error', {
status: response.status,
statusText: response.statusText,
error,
});
const traceSuffix = typeof error?.traceId === 'string' && error.traceId
? ` (traceId: ${error.traceId})`
: '';
throw new Error(`${error.error || 'Failed to generate commit message'}${traceSuffix}`);
}
const data = await response.json();
+62 -17
View File
@@ -620,6 +620,11 @@ class OpencodeService {
}>;
messageId?: string;
agentMentions?: Array<{ name: string; source?: { value: string; start: number; end: number } }>;
format?: {
type: 'json_schema';
schema: Record<string, unknown>;
retryCount?: number;
};
}): Promise<string> {
// Generate a temporary client-side ID for optimistic UI
// This ID won't be sent to the server - server will generate its own
@@ -693,27 +698,67 @@ class OpencodeService {
// for model work (SSE will deliver output/status).
// This avoids 504s from proxy timeouts on long-running turns.
const base = this.baseUrl.replace(/\/+$/, '');
const url = new URL(`${base}/session/${encodeURIComponent(params.id)}/prompt_async`);
if (this.currentDirectory) {
url.searchParams.set('directory', this.currentDirectory);
let url: URL;
try {
url = new URL(`${base}/session/${encodeURIComponent(params.id)}/prompt_async`);
if (this.currentDirectory) {
url.searchParams.set('directory', this.currentDirectory);
}
} catch (error) {
console.error('[git-generation][browser] failed to build prompt_async URL', {
baseUrl: this.baseUrl,
normalizedBase: base,
sessionId: params.id,
directory: this.currentDirectory,
message: error instanceof Error ? error.message : String(error),
error,
});
throw error;
}
const response = await fetch(url.toString(), {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify({
model: {
providerID: params.providerID,
modelID: params.modelID,
},
if (params.format) {
console.info('[git-generation][browser] send structured message', {
sessionId: params.id,
providerID: params.providerID,
modelID: params.modelID,
agent: params.agent,
variant: params.variant,
parts,
}),
});
directory: this.currentDirectory,
baseUrl: this.baseUrl,
formatType: params.format.type,
});
}
let response: Response;
try {
response = await fetch(url.toString(), {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify({
model: {
providerID: params.providerID,
modelID: params.modelID,
},
agent: params.agent,
variant: params.variant,
...(params.format ? { format: params.format } : {}),
parts,
}),
});
} catch (error) {
console.error('[git-generation][browser] prompt_async request failed before response', {
sessionId: params.id,
url: url.toString(),
directory: this.currentDirectory,
hasFormat: Boolean(params.format),
message: error instanceof Error ? error.message : String(error),
error,
});
throw error;
}
if (!response.ok) {
let detail = '';
+14
View File
@@ -186,6 +186,20 @@ export const TOOL_METADATA: Record<string, ToolMetadata> = {
category: 'ai',
outputLanguage: 'text',
inputFields: []
},
StructuredOutput: {
displayName: 'Structured Output',
category: 'ai',
outputLanguage: 'json',
inputFields: []
},
structuredoutput: {
displayName: 'Structured Output',
category: 'ai',
outputLanguage: 'json',
inputFields: []
}
};