-
Notifications
You must be signed in to change notification settings - Fork 0
feat(ai-provider,core): implement CodeGenerationProvider seam (G5 Phase 1) #494
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| --- | ||
| "@linchkit/core": minor | ||
| "@linchkit/cap-ai-provider": minor | ||
| --- | ||
|
|
||
| G5 Phase 1 — implement the `CodeGenerationProvider` seam. | ||
|
|
||
| `@linchkit/core` previously declared a `CodeGenerationProvider` interface | ||
| ("implemented by cap-ai-provider") but no implementation existed and the type was | ||
| not exported from any public barrel. This adds: | ||
|
|
||
| - `@linchkit/core/server` now exports the `CodeGenerationProvider`, | ||
| `CodeGenerationResult`, `ProjectContext`, and `QualityGateRunner` types so a | ||
| capability can implement the seam. | ||
| - `@linchkit/cap-ai-provider` exports `createCodeGenerationProvider(ai, options)` | ||
| — a thin adapter over the configured `AIService` (GLM/zhipu/etc. per | ||
| `linchkit.config`) that turns a prompt (+ optional context) into generated | ||
| TypeScript source. | ||
|
|
||
| This is the foundation for AI code generation of the irreducibly-code parts of a | ||
| proposal (action / event-handler / flow logic bodies). It only PRODUCES candidate | ||
| source as a string — it never writes files, runs code, or touches the approval / | ||
| graduation path. Generated source still flows through validation and double human | ||
| review (draft + graduation PR) before it can land. |
128 changes: 128 additions & 0 deletions
128
addons/ai-provider/cap-ai-provider/__tests__/code-generation-provider.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| /** | ||
| * CodeGenerationProvider (G5 Phase 1) — unit tests. | ||
| * | ||
| * Verifies the thin adapter over AIService: message shaping (context→system, | ||
| * prompt→user), defaults (temperature 0, taskType "code"), option forwarding, | ||
| * and the fail-loud behaviour when the AIService is not configured. A fake | ||
| * AIService is injected — no real model is called. | ||
| */ | ||
|
|
||
| import { describe, expect, test } from "bun:test"; | ||
| import type { AICompletionOptions, AICompletionResult, AIService } from "@linchkit/core"; | ||
| import { createCodeGenerationProvider } from "../src/code-generation-provider"; | ||
|
|
||
| function makeAI(opts: { configured?: boolean; content?: string } = {}): { | ||
| ai: AIService; | ||
| calls: AICompletionOptions[]; | ||
| } { | ||
| const calls: AICompletionOptions[] = []; | ||
| const ai: AIService = { | ||
| configured: opts.configured ?? true, | ||
| defaultProvider: "glm", | ||
| providerNames: ["glm"], | ||
| async complete(options: AICompletionOptions): Promise<AICompletionResult> { | ||
| calls.push(options); | ||
| return { | ||
| content: opts.content ?? "export const x = 1;", | ||
| usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, | ||
| model: "glm-4", | ||
| provider: "glm", | ||
| duration: 1, | ||
| }; | ||
| }, | ||
| }; | ||
| return { ai, calls }; | ||
| } | ||
|
|
||
| describe("createCodeGenerationProvider", () => { | ||
| test("returns the completion content verbatim", async () => { | ||
| const { ai } = makeAI({ content: "export const late_fee = defineRule({});" }); | ||
| const provider = createCodeGenerationProvider(ai); | ||
| const code = await provider.generateCode("Generate a late_fee rule"); | ||
| expect(code).toBe("export const late_fee = defineRule({});"); | ||
| }); | ||
|
|
||
| test("sends context as a system message and prompt as the user message", async () => { | ||
| const { ai, calls } = makeAI(); | ||
| const provider = createCodeGenerationProvider(ai); | ||
| await provider.generateCode("PROMPT", "CONTEXT"); | ||
| expect(calls).toHaveLength(1); | ||
| expect(calls[0]?.messages).toEqual([ | ||
| { role: "system", content: "CONTEXT" }, | ||
| { role: "user", content: "PROMPT" }, | ||
| ]); | ||
| expect(calls[0]?.temperature).toBe(0); | ||
| expect(calls[0]?.taskType).toBe("code"); | ||
| }); | ||
|
|
||
| test("omits the system message when no context is given", async () => { | ||
| const { ai, calls } = makeAI(); | ||
| const provider = createCodeGenerationProvider(ai); | ||
| await provider.generateCode("PROMPT"); | ||
| expect(calls[0]?.messages).toEqual([{ role: "user", content: "PROMPT" }]); | ||
| }); | ||
|
|
||
| test("omits the system message for whitespace-only context", async () => { | ||
| const { ai, calls } = makeAI(); | ||
| const provider = createCodeGenerationProvider(ai); | ||
| await provider.generateCode("PROMPT", " "); | ||
| expect(calls[0]?.messages).toEqual([{ role: "user", content: "PROMPT" }]); | ||
| }); | ||
|
|
||
| test("forwards model / maxTokens / temperature / tenantId / taskType overrides", async () => { | ||
| const { ai, calls } = makeAI(); | ||
| const provider = createCodeGenerationProvider(ai, { | ||
| model: "advanced", | ||
| maxTokens: 2048, | ||
| temperature: 0.2, | ||
| tenantId: "t-1", | ||
| taskType: "generation", | ||
| }); | ||
| await provider.generateCode("PROMPT"); | ||
| expect(calls[0]?.model).toBe("advanced"); | ||
| expect(calls[0]?.maxTokens).toBe(2048); | ||
| expect(calls[0]?.temperature).toBe(0.2); | ||
| expect(calls[0]?.tenantId).toBe("t-1"); | ||
| expect(calls[0]?.taskType).toBe("generation"); | ||
| }); | ||
|
|
||
| test("throws when the AIService is not configured", async () => { | ||
| const { ai } = makeAI({ configured: false }); | ||
| const provider = createCodeGenerationProvider(ai); | ||
| await expect(provider.generateCode("PROMPT")).rejects.toThrow(/not configured/); | ||
| }); | ||
|
|
||
| test("throws on an empty prompt; the model is never called", async () => { | ||
| const { ai, calls } = makeAI(); | ||
| const provider = createCodeGenerationProvider(ai); | ||
| await expect(provider.generateCode("")).rejects.toThrow(/non-empty string/); | ||
| expect(calls).toHaveLength(0); | ||
| }); | ||
|
|
||
| test("throws on a whitespace-only prompt; the model is never called", async () => { | ||
| const { ai, calls } = makeAI(); | ||
| const provider = createCodeGenerationProvider(ai); | ||
| await expect(provider.generateCode(" \n\t ")).rejects.toThrow(/non-empty string/); | ||
| expect(calls).toHaveLength(0); | ||
| }); | ||
|
|
||
| test("throws when the completion has no text content", async () => { | ||
| const ai: AIService = { | ||
| configured: true, | ||
| defaultProvider: "glm", | ||
| providerNames: ["glm"], | ||
| // Simulate a provider returning a result without usable text content. | ||
| async complete(): Promise<AICompletionResult> { | ||
| return { | ||
| content: undefined as unknown as string, | ||
| usage: { inputTokens: 1, outputTokens: 0, totalTokens: 1 }, | ||
| model: "glm-4", | ||
| provider: "glm", | ||
| duration: 1, | ||
| }; | ||
| }, | ||
| }; | ||
| const provider = createCodeGenerationProvider(ai); | ||
| await expect(provider.generateCode("PROMPT")).rejects.toThrow(/no text content/); | ||
| }); | ||
| }); | ||
87 changes: 87 additions & 0 deletions
87
addons/ai-provider/cap-ai-provider/src/code-generation-provider.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| /** | ||
| * CodeGenerationProvider implementation (G5 Phase 1). | ||
| * | ||
| * Implements core's `CodeGenerationProvider` seam. The interface in | ||
| * `@linchkit/core` is documented as "implemented by cap-ai-provider", but no | ||
| * implementation existed. This is a thin adapter over the configured | ||
| * {@link AIService} (GLM / zhipu / etc. per `linchkit.config`), intended for the | ||
| * proposal code-generation pipeline that materializes the irreducibly-code parts | ||
| * of a proposal — action / event-handler / flow logic bodies that a declarative | ||
| * `ChangeDefinition` cannot express — into TypeScript source. | ||
| * | ||
| * SAFETY BOUNDARY ("AI never modifies production directly"): this only PRODUCES | ||
| * candidate source as a string. It never writes files, runs code, or touches the | ||
| * approval / graduation path. Generated source must flow through validation | ||
| * (build + quality gates) and double human review (draft review + graduation PR) | ||
| * before it can land. | ||
| */ | ||
|
|
||
| import type { AICompletionOptions, AIMessage, AIService, AITaskType } from "@linchkit/core"; | ||
| import type { CodeGenerationProvider } from "@linchkit/core/server"; | ||
|
|
||
| export interface CodeGenerationProviderOptions { | ||
| /** | ||
| * Model alias (`fast` / `standard` / `advanced`) or a full model id. Omit to | ||
| * let the AIService pick its configured default provider/model. | ||
| */ | ||
| model?: string; | ||
| /** Sampling temperature. Defaults to 0 — code generation should be deterministic. */ | ||
| temperature?: number; | ||
| /** Max output tokens for a single generation. */ | ||
| maxTokens?: number; | ||
| /** Task-type hint for model routing. Defaults to `"code"`. */ | ||
| taskType?: AITaskType; | ||
| /** Tenant id for BYOK (bring-your-own-key) config resolution. */ | ||
| tenantId?: string; | ||
| } | ||
|
|
||
| /** | ||
| * Build a {@link CodeGenerationProvider} backed by a configured {@link AIService}. | ||
| * | ||
| * `generateCode(prompt, context?)` sends `context` as a system message (when | ||
| * non-empty) and `prompt` as the user message, returning the model's raw text | ||
| * content. It throws when the AIService is not configured, so the caller's retry | ||
| * / quality-gate loop surfaces a clear failure instead of silently treating an | ||
| * empty completion as generated code. | ||
| */ | ||
| export function createCodeGenerationProvider( | ||
| ai: AIService, | ||
| options: CodeGenerationProviderOptions = {}, | ||
| ): CodeGenerationProvider { | ||
| return { | ||
| async generateCode(prompt: string, context?: string): Promise<string> { | ||
| if (!ai.configured) { | ||
| throw new Error( | ||
| "CodeGenerationProvider: AIService is not configured — cannot generate code.", | ||
| ); | ||
| } | ||
|
laofahai marked this conversation as resolved.
|
||
| if (typeof prompt !== "string" || prompt.trim().length === 0) { | ||
| throw new Error("CodeGenerationProvider: prompt must be a non-empty string."); | ||
| } | ||
|
|
||
| const messages: AIMessage[] = []; | ||
| if (context && context.trim().length > 0) { | ||
| messages.push({ role: "system", content: context }); | ||
| } | ||
| messages.push({ role: "user", content: prompt }); | ||
|
|
||
| const completion: AICompletionOptions = { | ||
| messages, | ||
| temperature: options.temperature ?? 0, | ||
| taskType: options.taskType ?? "code", | ||
| }; | ||
| if (options.model) completion.model = options.model; | ||
| if (options.maxTokens !== undefined) completion.maxTokens = options.maxTokens; | ||
| if (options.tenantId) completion.tenantId = options.tenantId; | ||
|
|
||
| const result = await ai.complete(completion); | ||
| // A completion with no text content is a generation failure, not empty | ||
| // code — throw so the caller's retry / quality gate surfaces it rather than | ||
| // silently returning a non-string (the contract is Promise<string>). | ||
| if (typeof result?.content !== "string") { | ||
| throw new Error("CodeGenerationProvider: AI returned no text content."); | ||
| } | ||
| return result.content; | ||
|
laofahai marked this conversation as resolved.
|
||
| }, | ||
| }; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.