From 608300b47e1470870f6c89bbeb7edb4c42d47018 Mon Sep 17 00:00:00 2001 From: Md Shahriar Hossein Date: Wed, 16 Sep 2026 18:04:12 +0600 Subject: [PATCH] feat: add Ollama Cloud compatibility Support JSON-only fallback responses, preserve authentication headers during summarization, and tune generation limits for GPT-OSS models. --- README.md | 15 ++ package.json | 8 +- sampleWorkspace/.vscode/settings.json | 3 +- src/config.ts | 5 + src/generator.ts | 84 +++++++-- src/modelOptions.ts | 51 ++++++ src/ollamaAdapter.ts | 21 ++- src/scheduler.ts | 52 ++++-- src/schemas/commit.ts | 44 +++++ src/summarizer.ts | 7 +- src/test/background.test.ts | 88 +++++++++- src/test/extension.test.ts | 237 ++++++++++++++++++++++++++ src/test/liveCloud.test.ts | 69 ++++++++ src/test/ollamaAdapter.test.ts | 74 ++++++++ src/test/security.test.ts | 16 ++ src/types/config.d.ts | 1 + 16 files changed, 738 insertions(+), 37 deletions(-) create mode 100644 src/modelOptions.ts create mode 100644 src/test/liveCloud.test.ts create mode 100644 src/test/ollamaAdapter.test.ts diff --git a/README.md b/README.md index 1fe9557..38b8cc7 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ You can also set `commitollama.model` manually in VS Code settings. | `commitollama.useLowerCase` | Lowercase the first letter of the commit message. | `false` | | `commitollama.language` | Language preset (`English`, `Spanish`, `Custom`, …). | `English` | | `commitollama.promptTemperature` | Model temperature (`0`–`1`). Higher = more creative. | `0.2` | +| `commitollama.cloudCompatibilityMode` | Always use JSON-only prompting for Ollama Cloud models that do not support Ollama structured output. Commitollama also falls back to this mode automatically after an invalid structured response. | `false` | | `commitollama.commitTemplate` | Final commit format. Placeholders: `{{type}}`, `{{emoji}}`, `{{message}}`. | `{{type}} {{emoji}}: {{message}}` | ### Custom overrides @@ -69,6 +70,20 @@ You can also set `commitollama.model` manually in VS Code settings. | `commitollama.custom.descriptionPrompt` | Custom prompt for the commit description. | | `commitollama.custom.requestHeaders` | Extra HTTP headers for Ollama requests (e.g. auth). | +### Ollama Cloud compatibility + +For Ollama Cloud, configure its endpoint and authentication headers. Commitollama automatically retries a failed or incomplete structured response as JSON-only text, extracts a JSON object, and validates it against the commit schema. Enable `commitollama.cloudCompatibilityMode` to use this compatibility path on the first request instead. + +```json +{ + "commitollama.custom.endpoint": "", + "commitollama.custom.requestHeaders": { + "Authorization": "Bearer " + }, + "commitollama.cloudCompatibilityMode": true +} +``` + Example emoji map: ```json diff --git a/package.json b/package.json index d630611..37e96d5 100644 --- a/package.json +++ b/package.json @@ -115,11 +115,17 @@ "default": 0.2, "order": 7 }, + "commitollama.cloudCompatibilityMode": { + "type": "boolean", + "description": "Use JSON-only compatibility mode immediately for Ollama Cloud models that do not support structured output. Failed or incomplete structured responses automatically retry with this mode.", + "default": false, + "order": 8 + }, "commitollama.commitTemplate": { "type": "string", "description": "Custom template for commit messages.", "default": "{{type}} {{emoji}}: {{message}}", - "order": 8 + "order": 9 }, "commitollama.custom.language": { "type": "string", diff --git a/sampleWorkspace/.vscode/settings.json b/sampleWorkspace/.vscode/settings.json index d702250..aa89c67 100644 --- a/sampleWorkspace/.vscode/settings.json +++ b/sampleWorkspace/.vscode/settings.json @@ -3,5 +3,6 @@ "commitollama.useDescription": false, "commitollama.useLowerCase": false, "commitollama.commitTemplate": "{{type}} {{emoji}}: {{message}}", - "commitollama.custom.emojis": {} + "commitollama.custom.emojis": {}, + "commitollama.cloudCompatibilityMode": false } diff --git a/src/config.ts b/src/config.ts index 0a9d478..1d4ea53 100644 --- a/src/config.ts +++ b/src/config.ts @@ -17,6 +17,7 @@ export const defaultConfig = { language: Languages.English, commitTemplate: '{{type}} {{emoji}}: {{message}}', promptTemperature: 0.2, + cloudCompatibilityMode: false, requestHeaders: {}, emojis: { feat: '✨', @@ -78,6 +79,9 @@ class Config { const promptTemperature = getConfig('promptTemperature') || defaultConfig.promptTemperature + const cloudCompatibilityMode = + getConfig('cloudCompatibilityMode') ?? + defaultConfig.cloudCompatibilityMode const customPrompt = getConfig('custom.prompt') const customTypeRules = getConfig('custom.typeRules') @@ -94,6 +98,7 @@ class Config { return { commitEmojis, + cloudCompatibilityMode, promptTemperature, commitTemplate, customCommitMessageRules, diff --git a/src/generator.ts b/src/generator.ts index 810572a..44f731a 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -1,12 +1,15 @@ import * as vscode from 'vscode' +import { ZodError } from 'zod' import * as ai from './ai' import { isLowQualityCommit } from './commitQuality' import { config } from './config' import { OLLAMA_LIBRARY_URL } from './constants' +import { buildCommitModelOptions } from './modelOptions' import { createChatAdapter } from './ollamaAdapter' import { buildCommitSchema, type CommitStructure, + extractJsonObject, parseCommitResponse, } from './schemas/commit' import { formatExtensionError, logExtensionError } from './security/log' @@ -55,6 +58,15 @@ function isStructuredOutputCompatibilityError(error: unknown): boolean { ) } +function isCloudResponseValidationError(error: unknown): boolean { + return ( + error instanceof ZodError || + (error instanceof Error && + (error.message === 'Could not find a JSON object in the model response' || + isStructuredOutputCompatibilityError(error))) + ) +} + function formatChangeSummaries(summaries: ChangeSummary[]): string { return summaries .map(({ file, summary }) => `- ${file}: ${summary}`) @@ -93,6 +105,7 @@ function buildStructuredPrompt(options: { descriptionPrompt: string customPrompt?: string extraInstruction?: string + jsonOnly?: boolean }): string { const { typeRules, @@ -102,6 +115,7 @@ function buildStructuredPrompt(options: { descriptionPrompt, customPrompt, extraInstruction, + jsonOnly, } = options const basePrompt = @@ -126,11 +140,18 @@ function buildStructuredPrompt(options: { ${useDescription ? descriptionPrompt : ''} Respond using JSON` - if (!extraInstruction) { + const instructions = [ + extraInstruction, + jsonOnly + ? `Respond with exactly one valid JSON object and nothing else. Do not use Markdown, code fences, or explanatory text. Required fields: ${useDescription ? '{"type":"feat","message":"Brief commit subject","summary":"One to three sentence commit description"}' : '{"type":"feat","message":"Brief commit subject"}'}. The type value must be one of: feat, fix, docs, style, test, chore, revert, refactor.` + : undefined, + ].filter(Boolean) + + if (instructions.length === 0) { return basePrompt } - return `${basePrompt}\n\n${extraInstruction}` + return `${basePrompt}\n\n${instructions.join('\n\n')}` } async function requestStructuredCommit( @@ -138,9 +159,11 @@ async function requestStructuredCommit( options?: { extraInstruction?: string branchName?: string | null + jsonOnly?: boolean }, ): Promise { const { + model, promptTemperature, language, useDescription, @@ -148,7 +171,9 @@ async function requestStructuredCommit( customTypeRules, customCommitMessageRules, customDescriptionPrompt, + cloudCompatibilityMode, } = config.inference + const jsonOnly = options?.jsonOnly ?? cloudCompatibilityMode const typeRules = customTypeRules || @@ -180,28 +205,34 @@ async function requestStructuredCommit( descriptionPrompt, customPrompt, extraInstruction: options?.extraInstruction, + jsonOnly, }) const outputSchema = buildCommitSchema(useDescription, language) - const result = await ai.chat({ + const chatOptions = { adapter: createChatAdapter(), systemPrompts: [structuredPrompt], messages: [ { - role: 'user', + role: 'user' as const, content: buildCommitUserContent(summaries, options?.branchName), }, ], - outputSchema, - modelOptions: { - options: { - temperature: promptTemperature, - num_predict: 256, - }, - think: false, - } as never, - }) + modelOptions: buildCommitModelOptions(model, promptTemperature), + } + + if (jsonOnly) { + const result = await ai.chat({ ...chatOptions, stream: false }) + const commit = parseCommitResponse( + extractJsonObject(result), + useDescription, + language, + ) + return commit + } + + const result = await ai.chat({ ...chatOptions, outputSchema }) return parseCommitResponse(result, useDescription, language) } @@ -211,11 +242,36 @@ export async function generateStructuredCommit( branchName?: string | null, ): Promise { try { - let commit = await requestStructuredCommit(summaries, { branchName }) + let commit: CommitStructure + let jsonOnly = config.inference.cloudCompatibilityMode + + try { + commit = await requestStructuredCommit(summaries, { + branchName, + jsonOnly, + }) + } catch (error) { + if (!isCloudResponseValidationError(error)) { + throw error + } + + // Cloud models frequently accept chat requests but either reject + // Ollama's structured-output format or omit an optional-looking field + // such as the enabled commit description. Retry as JSON-only text even + // when the compatibility setting was not enabled in advance. + jsonOnly = true + commit = await requestStructuredCommit(summaries, { + branchName, + jsonOnly, + extraInstruction: + 'Your previous response was invalid. Return exactly one JSON object with every required field from the requested format.', + }) + } if (isLowQualityCommit(commit.message, commit.type)) { commit = await requestStructuredCommit(summaries, { branchName, + jsonOnly, extraInstruction: 'The previous response was invalid because it did not describe the staged changes. Use the summaries exactly and describe the real code changes.', }) diff --git a/src/modelOptions.ts b/src/modelOptions.ts new file mode 100644 index 0000000..ada9748 --- /dev/null +++ b/src/modelOptions.ts @@ -0,0 +1,51 @@ +export function isGptOssModel(model: string): boolean { + return /^gpt-oss(?::|$)/i.test(model) +} + +export interface OllamaGenerationOptions { + temperature: number + num_predict?: number +} + +export interface OllamaModelOptions { + options: OllamaGenerationOptions + think?: 'low' | false +} + +export function buildCommitModelOptions( + model: string, + promptTemperature: number, +): OllamaModelOptions { + const isGptOss = isGptOssModel(model) + + return { + options: { + temperature: promptTemperature, + // GPT-OSS cannot disable reasoning. Give its low-effort trace enough + // room to finish before it emits the short, visible JSON response. + num_predict: isGptOss ? 2048 : 256, + }, + think: isGptOss ? 'low' : false, + } +} + +export function buildSummarizeModelOptions( + model: string, + promptTemperature: number, +): OllamaModelOptions { + if (!isGptOssModel(model)) { + return { + options: { temperature: promptTemperature }, + } + } + + return { + options: { + temperature: promptTemperature, + // The summarize adapter otherwise maps maxLength directly to an + // 80-token generation cap, which GPT-OSS can exhaust on reasoning. + num_predict: 1024, + }, + think: 'low', + } +} diff --git a/src/ollamaAdapter.ts b/src/ollamaAdapter.ts index 43fd1c8..050ae3f 100644 --- a/src/ollamaAdapter.ts +++ b/src/ollamaAdapter.ts @@ -1,5 +1,9 @@ -import type { AnySummarizeAdapter, AnyTextAdapter } from '@tanstack/ai' -import { createOllamaChat, createOllamaSummarize } from '@tanstack/ai-ollama' +import { + ChatStreamSummarizeAdapter, + type AnySummarizeAdapter, + type AnyTextAdapter, +} from '@tanstack/ai/adapters' +import { createOllamaChat } from '@tanstack/ai-ollama' import { config } from './config' export interface OllamaConnectionConfig { @@ -38,6 +42,15 @@ export function createChatAdapter(): AnyTextAdapter { export function createSummarizeAdapter() { const { model } = config.inference - const { host } = getOllamaConnectionConfig() - return createOllamaSummarize(model, host) as unknown as AnySummarizeAdapter + const { host, headers } = getOllamaConnectionConfig() + + // createOllamaSummarize accepts only a host, so it silently loses custom + // headers (including the authorization header required by Ollama Cloud). + // Wrap the configured chat adapter instead so summaries and commit messages + // use the identical connection configuration. + return new ChatStreamSummarizeAdapter( + createOllamaChat(model, { host, headers }), + model, + 'ollama', + ) as unknown as AnySummarizeAdapter } diff --git a/src/scheduler.ts b/src/scheduler.ts index 10df8de..c2a2d25 100644 --- a/src/scheduler.ts +++ b/src/scheduler.ts @@ -2,15 +2,28 @@ import * as vscode from 'vscode' import { summaryCache } from './cache' import { isLowQualitySummary } from './commitQuality' import { config } from './config' +import { buildDiffFallbackSummary } from './diffFallback' import { logExtensionError } from './security/log' import { summarizeFileDiff } from './summarizer' import { getGitExtension } from './utils' +interface BackgroundScannerDependencies { + getGitExtension: typeof getGitExtension + summarizeFileDiff: typeof summarizeFileDiff +} + export class BackgroundScanner { private intervalId: NodeJS.Timeout | undefined private disposables: vscode.Disposable[] = [] + private scanInProgress = false + private readonly dependencies: BackgroundScannerDependencies - constructor() { + constructor(dependencies: Partial = {}) { + this.dependencies = { + getGitExtension, + summarizeFileDiff, + ...dependencies, + } this.start() } @@ -48,7 +61,7 @@ export class BackgroundScanner { const { background } = config.inference if (background.enabled && background.interval > 0) { this.intervalId = setInterval(() => { - this.scanOpenRepositories() + void this.scanOpenRepositories() }, background.interval * 1000) } } @@ -65,22 +78,31 @@ export class BackgroundScanner { } private async scanOpenRepositories() { - const git = getGitExtension() + if (this.scanInProgress) { + return + } + + const git = this.dependencies.getGitExtension() if (!git) { return } - for (const repo of git.repositories) { - const changes = await repo.diffWithHEAD() - for (const change of changes) { - await this.processFile(change.uri) + this.scanInProgress = true + try { + for (const repo of git.repositories) { + const changes = await repo.diffWithHEAD() + for (const change of changes) { + await this.processFile(change.uri) + } } + } finally { + this.scanInProgress = false } } private async processFile(uri: vscode.Uri) { try { - const git = getGitExtension() + const git = this.dependencies.getGitExtension() const repo = git?.repositories.find((r) => uri.fsPath.startsWith(r.rootUri.fsPath), ) @@ -101,10 +123,18 @@ export class BackgroundScanner { return } - const summary = await summarizeFileDiff(workingDiff) - if (!isLowQualitySummary(summary)) { - summaryCache.set(uri.fsPath, hash, summary) + let summary: string + try { + summary = await this.dependencies.summarizeFileDiff(workingDiff) + } catch (error) { + logExtensionError(`backgroundScan ${uri.fsPath}`, error) + summary = buildDiffFallbackSummary(relativePath, workingDiff) + } + + if (isLowQualitySummary(summary)) { + summary = buildDiffFallbackSummary(relativePath, workingDiff) } + summaryCache.set(uri.fsPath, hash, summary) } catch (error) { logExtensionError(`backgroundScan ${uri.fsPath}`, error) } diff --git a/src/schemas/commit.ts b/src/schemas/commit.ts index f53d88f..eb56e0a 100644 --- a/src/schemas/commit.ts +++ b/src/schemas/commit.ts @@ -19,6 +19,50 @@ export type CommitStructure = { summary?: string } +export function extractJsonObject(text: string): unknown { + for ( + let start = text.indexOf('{'); + start !== -1; + start = text.indexOf('{', start + 1) + ) { + let depth = 0 + let inString = false + let escaped = false + + for (let index = start; index < text.length; index++) { + const character = text[index] + + if (inString) { + if (escaped) { + escaped = false + } else if (character === '\\') { + escaped = true + } else if (character === '"') { + inString = false + } + continue + } + + if (character === '"') { + inString = true + } else if (character === '{') { + depth++ + } else if (character === '}') { + depth-- + if (depth === 0) { + try { + return JSON.parse(text.slice(start, index + 1)) + } catch { + break + } + } + } + } + } + + throw new Error('Could not find a JSON object in the model response') +} + export function buildCommitSchema(useDescription: boolean, language: string) { const base = z.object({ type: z diff --git a/src/summarizer.ts b/src/summarizer.ts index 5aff617..57347b6 100644 --- a/src/summarizer.ts +++ b/src/summarizer.ts @@ -1,5 +1,6 @@ import * as ai from './ai' import { config } from './config' +import { buildSummarizeModelOptions } from './modelOptions' import { createSummarizeAdapter } from './ollamaAdapter' import { formatExtensionError, logExtensionError } from './security/log' import { wrapUntrustedContent } from './security/prompt' @@ -21,11 +22,7 @@ export async function summarizeFileDiff(diff: string): Promise { 'Do not say there are no changes, an empty diff, or missing input', 'Do not start with "The code changes..." or "This file..."', ], - modelOptions: { - options: { - temperature: promptTemperature, - }, - }, + modelOptions: buildSummarizeModelOptions(model, promptTemperature), }) return result.summary.trim() diff --git a/src/test/background.test.ts b/src/test/background.test.ts index 9ce7a1c..d3c7990 100644 --- a/src/test/background.test.ts +++ b/src/test/background.test.ts @@ -1,7 +1,10 @@ import * as assert from 'node:assert' import * as sinon from 'sinon' +import * as vscode from 'vscode' import * as ai from '../ai' -import { SummaryCache } from '../cache' +import { SummaryCache, summaryCache } from '../cache' +import { buildSummarizeModelOptions } from '../modelOptions' +import { BackgroundScanner } from '../scheduler' import { summarizeFileDiff } from '../summarizer' suite('Background Scanning Tests', () => { @@ -65,5 +68,88 @@ suite('Background Scanning Tests', () => { await assert.rejects(() => summarizeFileDiff('diff'), /Ollama failed/) }) + + test('should leave room for GPT-OSS reasoning and visible output', () => { + const options = buildSummarizeModelOptions( + 'gpt-oss:20b-cloud', + 0.2, + ) as unknown as { + think: string + options: { num_predict: number } + } + + assert.strictEqual(options.think, 'low') + assert.strictEqual(options.options.num_predict, 1024) + }) + }) + + suite('BackgroundScanner', () => { + teardown(() => { + summaryCache.clear() + }) + + test('should cache a diff fallback when summarization fails', async () => { + const rootUri = vscode.Uri.file('/workspace') + const fileUri = vscode.Uri.file('/workspace/src/example.ts') + const diff = [ + 'diff --git a/src/example.ts b/src/example.ts', + '@@ -0,0 +1 @@', + '+export const answer = 42', + ].join('\n') + const repository = { + rootUri, + diffWithHEAD: sinon.stub().resolves(diff), + } + const scanner = new BackgroundScanner({ + getGitExtension: () => + ({ repositories: [repository] }) as never, + summarizeFileDiff: sinon.stub().rejects(new Error('offline')), + }) + scanner.stop() + + try { + await ( + scanner as unknown as { + processFile(uri: vscode.Uri): Promise + } + ).processFile(fileUri) + + const cached = summaryCache.get(fileUri.fsPath) + assert.strictEqual(cached?.diffHash, summaryCache.computeHash(diff)) + assert.match(cached?.summary ?? '', /add 1 line/i) + assert.match(cached?.summary ?? '', /answer = 42/) + } finally { + scanner.stop() + } + }) + + test('should skip an overlapping repository scan', async () => { + let releaseDiff: ((changes: []) => void) | undefined + const diffPending = new Promise<[]>((resolve) => { + releaseDiff = resolve + }) + const diffWithHEAD = sinon.stub().returns(diffPending) + const scanner = new BackgroundScanner({ + getGitExtension: () => + ({ repositories: [{ diffWithHEAD }] }) as never, + }) + scanner.stop() + const scan = ( + scanner as unknown as { + scanOpenRepositories(): Promise + } + ).scanOpenRepositories.bind(scanner) + + try { + const firstScan = scan() + await scan() + assert.strictEqual(diffWithHEAD.callCount, 1) + + releaseDiff?.([]) + await firstScan + } finally { + scanner.stop() + } + }) }) }) diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 7874c32..5aa1462 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -11,6 +11,7 @@ import { getCommitMessage, normalizeBranchName, } from '../generator' +import { extractJsonObject } from '../schemas/commit' import { getConfig, getGitExtension, setConfig } from '../utils' suite('Extension Test Suite', () => { @@ -139,6 +140,207 @@ suite('generateStructuredCommit Tests', () => { assert.ok(systemPrompt.includes('current branch name as optional context')) }) + test('Should use a text request and parse Cloud JSON responses', async () => { + const originalCloudCompatibilityMode = getConfig('cloudCompatibilityMode') + await setConfig('cloudCompatibilityMode', true) + chatStub.resolves( + '```json\n{"type":"feat","message":"Add new feature"}\n```', + ) + + try { + const result = await generateStructuredCommit(summariesSample) + const chatArgs = chatStub.firstCall.args[0] + + assert.strictEqual(result.type, 'feat') + assert.strictEqual(chatArgs.outputSchema, undefined) + assert.strictEqual(chatArgs.stream, false) + assert.ok( + chatArgs.systemPrompts[0].includes( + 'Respond with exactly one valid JSON object', + ), + ) + } finally { + await setConfig( + 'cloudCompatibilityMode', + originalCloudCompatibilityMode ?? defaultConfig.cloudCompatibilityMode, + ) + } + }) + + test('Should preserve output budget for GPT-OSS reasoning', async () => { + const originalCloudCompatibilityMode = getConfig('cloudCompatibilityMode') + const originalModel = vscode.workspace + .getConfiguration('commitollama') + .inspect('model')?.workspaceValue + await setConfig('cloudCompatibilityMode', true) + await setConfig('model', 'gpt-oss:20b-cloud') + chatStub.resolves('{"type":"feat","message":"Add new feature"}') + + try { + await generateStructuredCommit(summariesSample) + + const modelOptions = chatStub.firstCall.args[0].modelOptions + assert.strictEqual(modelOptions.think, 'low') + assert.strictEqual(modelOptions.options.num_predict, 2048) + } finally { + await setConfig( + 'cloudCompatibilityMode', + originalCloudCompatibilityMode ?? defaultConfig.cloudCompatibilityMode, + ) + await vscode.workspace + .getConfiguration('commitollama') + .update( + 'model', + originalModel, + vscode.ConfigurationTarget.Workspace, + ) + } + }) + + test('Should request and validate localized GPT-OSS Cloud descriptions', async () => { + const originalCloudCompatibilityMode = getConfig('cloudCompatibilityMode') + const originalModel = vscode.workspace + .getConfiguration('commitollama') + .inspect('model')?.workspaceValue + const originalUseDescription = getConfig('useDescription') + const originalLanguage = vscode.workspace + .getConfiguration('commitollama') + .inspect('language')?.workspaceValue + const originalDescriptionPrompt = vscode.workspace + .getConfiguration('commitollama') + .inspect('custom.descriptionPrompt')?.workspaceValue + await setConfig('cloudCompatibilityMode', true) + await setConfig('model', 'gpt-oss:20b-cloud') + await setConfig('useDescription', true) + await setConfig('language', 'Spanish') + await setConfig( + 'custom.descriptionPrompt', + 'Describe el impacto para las personas usuarias.', + ) + chatStub.resolves( + '{"type":"feat","message":"Agregar función","summary":"Agrega la función y corrige el error."}', + ) + + try { + const result = await generateStructuredCommit(summariesSample) + + assert.strictEqual( + result.summary, + 'Agrega la función y corrige el error.', + ) + assert.strictEqual(chatStub.callCount, 1) + const prompt = chatStub.firstCall.args[0].systemPrompts[0] as string + assert.ok(prompt.includes('Write the message in spanish')) + assert.ok(prompt.includes('Describe el impacto')) + assert.ok(prompt.includes('"summary"')) + } finally { + await setConfig( + 'cloudCompatibilityMode', + originalCloudCompatibilityMode ?? defaultConfig.cloudCompatibilityMode, + ) + await setConfig('useDescription', originalUseDescription ?? false) + const configuration = vscode.workspace.getConfiguration('commitollama') + await configuration.update( + 'language', + originalLanguage, + vscode.ConfigurationTarget.Workspace, + ) + await configuration.update( + 'custom.descriptionPrompt', + originalDescriptionPrompt, + vscode.ConfigurationTarget.Workspace, + ) + await vscode.workspace + .getConfiguration('commitollama') + .update( + 'model', + originalModel, + vscode.ConfigurationTarget.Workspace, + ) + } + }) + + test('Should retry Cloud responses that do not contain JSON', async () => { + const originalCloudCompatibilityMode = getConfig('cloudCompatibilityMode') + await setConfig('cloudCompatibilityMode', true) + chatStub.onFirstCall().resolves('I would use a feat commit.') + chatStub + .onSecondCall() + .resolves('{"type":"feat","message":"Add new feature"}') + + try { + const result = await generateStructuredCommit(summariesSample) + + assert.strictEqual(result.type, 'feat') + assert.strictEqual(chatStub.callCount, 2) + assert.ok( + chatStub.secondCall.args[0].systemPrompts[0].includes( + 'previous response was invalid', + ), + ) + } finally { + await setConfig( + 'cloudCompatibilityMode', + originalCloudCompatibilityMode ?? defaultConfig.cloudCompatibilityMode, + ) + } + }) + + test('Should retry Cloud responses that omit the enabled summary', async () => { + const originalCloudCompatibilityMode = getConfig('cloudCompatibilityMode') + const originalUseDescription = getConfig('useDescription') + await setConfig('cloudCompatibilityMode', true) + await setConfig('useDescription', true) + chatStub + .onFirstCall() + .resolves('{"type":"feat","message":"Add new feature"}') + chatStub + .onSecondCall() + .resolves( + '{"type":"feat","message":"Add new feature","summary":"Adds the requested feature."}', + ) + + try { + const result = await generateStructuredCommit(summariesSample) + + assert.strictEqual(result.summary, 'Adds the requested feature.') + assert.strictEqual(chatStub.callCount, 2) + assert.ok( + chatStub.firstCall.args[0].systemPrompts[0].includes('"summary"'), + ) + } finally { + await setConfig( + 'cloudCompatibilityMode', + originalCloudCompatibilityMode ?? defaultConfig.cloudCompatibilityMode, + ) + await setConfig('useDescription', originalUseDescription ?? false) + } + }) + + test('Should fall back to JSON-only output when structured output omits summary', async () => { + const originalUseDescription = getConfig('useDescription') + await setConfig('useDescription', true) + chatStub + .onFirstCall() + .resolves({ type: 'feat', message: 'Add new feature' }) + chatStub + .onSecondCall() + .resolves( + '{"type":"feat","message":"Add new feature","summary":"Adds the requested feature."}', + ) + + try { + const result = await generateStructuredCommit(summariesSample) + + assert.strictEqual(result.summary, 'Adds the requested feature.') + assert.strictEqual(chatStub.callCount, 2) + assert.strictEqual(chatStub.secondCall.args[0].outputSchema, undefined) + assert.strictEqual(chatStub.secondCall.args[0].stream, false) + } finally { + await setConfig('useDescription', originalUseDescription ?? false) + } + }) + test('Should describe detached HEAD when branch name is missing', async () => { await generateStructuredCommit(summariesSample) @@ -148,6 +350,41 @@ suite('generateStructuredCommit Tests', () => { }) }) +suite('Cloud JSON response parser', () => { + test('parses a raw JSON object', () => { + assert.deepStrictEqual( + extractJsonObject('{"type":"feat","message":"Add feature"}'), + { + type: 'feat', + message: 'Add feature', + }, + ) + }) + + test('parses a JSON fenced response', () => { + assert.deepStrictEqual( + extractJsonObject('```json\n{"type":"fix","message":"Fix bug"}\n```'), + { type: 'fix', message: 'Fix bug' }, + ) + }) + + test('parses a generically fenced response', () => { + assert.deepStrictEqual( + extractJsonObject('```\n{"type":"docs","message":"Update README"}\n```'), + { type: 'docs', message: 'Update README' }, + ) + }) + + test('parses a JSON object embedded in text', () => { + assert.deepStrictEqual( + extractJsonObject( + 'Here is the commit: {"type":"test","message":"Add parser tests"}. നന്ദി!', + ), + { type: 'test', message: 'Add parser tests' }, + ) + }) +}) + suite('branch context helpers', () => { test('normalizeBranchName trims and drops empty values', () => { assert.strictEqual(normalizeBranchName(' feat/x '), 'feat/x') diff --git a/src/test/liveCloud.test.ts b/src/test/liveCloud.test.ts new file mode 100644 index 0000000..8b16b43 --- /dev/null +++ b/src/test/liveCloud.test.ts @@ -0,0 +1,69 @@ +import * as assert from 'node:assert' +import * as sinon from 'sinon' +import * as vscode from 'vscode' +import * as ai from '../ai' +import type { ChangeSummary } from '../generator' +import { getCommitMessage } from '../generator' +import { setConfig } from '../utils' + +const liveSuite = + process.env.COMMITOLLAMA_LIVE_MODEL_TEST === '1' ? suite : suite.skip + +liveSuite('Live Ollama Cloud Tests', () => { + test('GPT-OSS generates a commit with a model-written description in one call', async function () { + this.timeout(30_000) + + const originalModel = vscode.workspace + .getConfiguration('commitollama') + .inspect('model')?.workspaceValue + const originalCompatibilityMode = vscode.workspace + .getConfiguration('commitollama') + .inspect('cloudCompatibilityMode')?.workspaceValue + const originalUseDescription = vscode.workspace + .getConfiguration('commitollama') + .inspect('useDescription')?.workspaceValue + const chatSpy = sinon.spy(ai.llm, 'chat') + const summaries: ChangeSummary[] = [ + { + file: 'src/example.ts', + summary: 'Add a function that returns the sum of two numbers.', + }, + { + file: 'src/example.test.ts', + summary: 'Add unit tests for the sum function.', + }, + ] + + await setConfig('model', 'gpt-oss:20b-cloud') + await setConfig('cloudCompatibilityMode', true) + await setConfig('useDescription', true) + + try { + const commit = await getCommitMessage(summaries, 'test/live-cloud') + + assert.match( + commit, + /^(feat|fix|docs|style|test|chore|revert|refactor): .+\n\n.+/, + ) + assert.strictEqual(chatSpy.callCount, 1) + } finally { + sinon.restore() + const configuration = vscode.workspace.getConfiguration('commitollama') + await configuration.update( + 'cloudCompatibilityMode', + originalCompatibilityMode, + vscode.ConfigurationTarget.Workspace, + ) + await configuration.update( + 'useDescription', + originalUseDescription, + vscode.ConfigurationTarget.Workspace, + ) + await configuration.update( + 'model', + originalModel, + vscode.ConfigurationTarget.Workspace, + ) + } + }) +}) diff --git a/src/test/ollamaAdapter.test.ts b/src/test/ollamaAdapter.test.ts new file mode 100644 index 0000000..95a756b --- /dev/null +++ b/src/test/ollamaAdapter.test.ts @@ -0,0 +1,74 @@ +import * as assert from 'node:assert' +import * as vscode from 'vscode' +import { + createChatAdapter, + createSummarizeAdapter, +} from '../ollamaAdapter' +import { setConfig } from '../utils' + +interface OllamaClientConfig { + host: string + headers: Record +} + +interface OllamaTextAdapterInternals { + client: { config: OllamaClientConfig } +} + +interface SummarizeAdapterInternals { + textAdapter: OllamaTextAdapterInternals +} + +suite('Ollama adapter configuration', () => { + const endpoint = 'https://ollama.example.test' + const headers = { + Authorization: 'Bearer test-token', + 'X-Workspace': 'commitollama', + } + let originalEndpoint: string | undefined + let originalHeaders: Record | undefined + + setup(async () => { + const configuration = vscode.workspace.getConfiguration('commitollama') + originalEndpoint = configuration.inspect( + 'custom.endpoint', + )?.workspaceValue + originalHeaders = configuration.inspect>( + 'custom.requestHeaders', + )?.workspaceValue + await setConfig('custom.endpoint', endpoint) + await setConfig('custom.requestHeaders', headers) + }) + + teardown(async () => { + const configuration = vscode.workspace.getConfiguration('commitollama') + await configuration.update( + 'custom.endpoint', + originalEndpoint, + vscode.ConfigurationTarget.Workspace, + ) + await configuration.update( + 'custom.requestHeaders', + originalHeaders, + vscode.ConfigurationTarget.Workspace, + ) + }) + + test('forwards headers to chat and summarization transports', () => { + const chatAdapter = + createChatAdapter() as unknown as OllamaTextAdapterInternals + const summarizeAdapter = + createSummarizeAdapter() as unknown as SummarizeAdapterInternals + + assert.strictEqual(chatAdapter.client.config.host, `${endpoint}:443`) + assert.deepStrictEqual(chatAdapter.client.config.headers, headers) + assert.strictEqual( + summarizeAdapter.textAdapter.client.config.host, + `${endpoint}:443`, + ) + assert.deepStrictEqual( + summarizeAdapter.textAdapter.client.config.headers, + headers, + ) + }) +}) diff --git a/src/test/security.test.ts b/src/test/security.test.ts index d4187f0..a14b043 100644 --- a/src/test/security.test.ts +++ b/src/test/security.test.ts @@ -42,6 +42,22 @@ suite('Security Tests', () => { assert.strictEqual(result.summary, 'Extended summary') }) + test('Should reject a whitespace-only description', () => { + assert.throws( + () => + parseCommitResponse( + { + type: 'feat', + message: 'Add new feature', + summary: ' ', + }, + true, + 'english', + ), + (error) => error instanceof ZodError, + ) + }) + test('Should reject missing fields', () => { assert.throws( () => parseCommitResponse({ type: 'feat' }, false, 'english'), diff --git a/src/types/config.d.ts b/src/types/config.d.ts index fc28cfe..a5e1fc8 100644 --- a/src/types/config.d.ts +++ b/src/types/config.d.ts @@ -7,6 +7,7 @@ export type ExtensionConfig = { useLowerCase: boolean language: Language promptTemperature: number + cloudCompatibilityMode: boolean commitTemplate: string 'custom.language'?: string 'custom.emojis'?: EmojisMap