Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions backend/src/routes/change-walkthroughs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ export function createChangeWalkthroughRoutes(service: ChangeWalkthroughService)
}

try {
const { walkthrough, created } = await service.generate(c.req.param('sessionId'), parsed.data)
return c.json({ walkthrough }, created ? 201 : 200)
const state = await service.startGeneration(c.req.param('sessionId'), parsed.data)
return c.json(state, state.generating ? 202 : 200)
} catch (error) {
return handleServiceError(c, error, 'Failed to generate change walkthrough', ServiceError)
}
Expand Down
86 changes: 74 additions & 12 deletions backend/src/services/change-walkthroughs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type ChangeWalkthrough,
type ChangeWalkthroughState,
type GenerateChangeWalkthroughRequest,
type WalkthroughGenerationError,
type WalkthroughHunk,
type WalkthroughOmittedFile,
type WalkthroughStop,
Expand Down Expand Up @@ -212,8 +213,26 @@ export function parseWalkthroughResponse(text: string, hunks: WalkthroughHunk[])
}
}

interface InFlightGeneration {
modelStarted: Promise<void>
result: Promise<{ walkthrough: ChangeWalkthrough; created: boolean }>
}

function toGenerationError(error: unknown): WalkthroughGenerationError {
if (error instanceof ServiceError) {
return {
message: error.message,
...(error.code ? { code: error.code } : {}),
...(error.details !== undefined ? { details: error.details } : {}),
}
}

return { message: getErrorMessage(error) || 'Failed to generate the change walkthrough' }
}

export class ChangeWalkthroughService {
private readonly inFlight = new Map<string, Promise<{ walkthrough: ChangeWalkthrough; created: boolean }>>()
private readonly inFlight = new Map<string, InFlightGeneration>()
private readonly failures = new Map<string, WalkthroughGenerationError>()
private readonly deletedDuringGeneration = new Set<string>()
private readonly timeoutMs: number

Expand Down Expand Up @@ -241,21 +260,19 @@ export class ChangeWalkthroughService {
walkthrough,
currentDiffHash,
stale: walkthrough !== null && currentDiffHash !== null && walkthrough.diffHash !== currentDiffHash,
generating: this.inFlight.has(sessionId),
error: this.failures.get(sessionId) ?? null,
}
}

generate(sessionId: string, request: GenerateChangeWalkthroughRequest): Promise<{ walkthrough: ChangeWalkthrough; created: boolean }> {
const existing = this.inFlight.get(sessionId)
if (existing) {
return existing
}
return this.begin(sessionId, request).result
}

const pending = this.runGenerate(sessionId, request).finally(() => {
this.inFlight.delete(sessionId)
this.deletedDuringGeneration.delete(sessionId)
})
this.inFlight.set(sessionId, pending)
return pending
async startGeneration(sessionId: string, request: GenerateChangeWalkthroughRequest): Promise<ChangeWalkthroughState> {
const entry = this.begin(sessionId, request)
await Promise.race([entry.modelStarted, entry.result])
return this.getState(sessionId)
}

/** Removes the stored walkthrough of a deleted session, including one still being generated. */
Expand All @@ -268,12 +285,51 @@ export class ChangeWalkthroughService {
if (this.inFlight.has(sessionID)) {
this.deletedDuringGeneration.add(sessionID)
}
this.failures.delete(sessionID)
deleteChangeWalkthrough(this.db, sessionID)
}

private begin(sessionId: string, request: GenerateChangeWalkthroughRequest): InFlightGeneration {
const existing = this.inFlight.get(sessionId)
if (existing) {
return existing
}

this.failures.delete(sessionId)

let resolveModelStarted: () => void = () => {}
const modelStarted = new Promise<void>((resolve) => {
resolveModelStarted = resolve
})
let modelCalled = false
const markModelStarted = () => {
modelCalled = true
resolveModelStarted()
}

const result = this.runGenerate(sessionId, request, markModelStarted)
.catch((error) => {
if (modelCalled) {
this.failures.set(sessionId, toGenerationError(error))
}
throw error
})
.finally(() => {
this.inFlight.delete(sessionId)
this.deletedDuringGeneration.delete(sessionId)
})

result.catch(() => {})

const entry: InFlightGeneration = { modelStarted, result }
this.inFlight.set(sessionId, entry)
return entry
}

private async runGenerate(
sessionId: string,
request: GenerateChangeWalkthroughRequest,
onModelStart: () => void,
): Promise<{ walkthrough: ChangeWalkthrough; created: boolean }> {
const session = await this.readSession(sessionId)

Expand Down Expand Up @@ -314,9 +370,15 @@ export class ChangeWalkthroughService {

const prompt = buildWalkthroughPrompt({ title, hunks })

onModelStart()

let responseText: string
try {
responseText = await generateTextWithTimeout(this.openCodeClient, { prompt }, this.timeoutMs)
responseText = await generateTextWithTimeout(
this.openCodeClient,
{ prompt, model: session.model },
this.timeoutMs,
)
} catch (error) {
if (error instanceof GenerateTextTimeoutError) {
throw new ChangeWalkthroughError('Generating the change walkthrough timed out', 504, {
Expand Down
22 changes: 18 additions & 4 deletions backend/src/services/opencode/generate-text.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { ModelRef } from '@opencode-manager/shared/opencode'
import { getOpenCodeGlobalConfigPath } from '@opencode-manager/shared/config/env'
import type { OpenCodeClient } from './client'
import { resolveOpenCodeModel } from '../opencode-models'

export class GenerateTextTimeoutError extends Error {
constructor() {
Expand All @@ -23,12 +25,24 @@ export async function generateTextWithTimeout(
})

try {
const { text } = await Promise.race([
client.api.generate.text(input, { signal: controller.signal }),
timeout,
])
const { text } = await Promise.race([generateText(client, input, controller.signal), timeout])
return text
} finally {
if (timer) clearTimeout(timer)
}
}

async function generateText(
client: OpenCodeClient,
input: { prompt: string; model?: ModelRef },
signal: AbortSignal,
): Promise<{ text: string }> {
const model = input.model ?? await resolveGenerateModel(client, signal)
return client.api.generate.text({ prompt: input.prompt, model }, { signal })
}

/** OpenCode serves generation from its global config location, so the model must be resolved there once its catalog has loaded. */
async function resolveGenerateModel(client: OpenCodeClient, signal: AbortSignal): Promise<ModelRef> {
const { providerID, id, variant } = await resolveOpenCodeModel(client, getOpenCodeGlobalConfigPath(), { signal })
return variant ? { providerID, id, variant } : { providerID, id }
}
12 changes: 12 additions & 0 deletions backend/test/helpers/stub-opencode-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@ import { vi } from 'vitest'
import type { OpenCodeApi } from '@opencode-manager/shared/opencode'
import type { OpenCodeClient } from '../../src/services/opencode/client'

/** OpenCode API stubs for a catalog that has finished loading, so model resolution succeeds on the first poll. */
export function stubLoadedModelCatalog() {
const model = { providerID: 'openai', id: 'gpt-5-mini', enabled: true }
return {
config: { get: vi.fn(async () => []) },
model: {
list: vi.fn(async () => ({ data: [model] })),
default: vi.fn(async () => ({ data: model })),
},
}
}

export function createStubOpenCodeClient(overrides: Partial<OpenCodeClient> = {}): OpenCodeClient {
return {
api: {
Expand Down
105 changes: 85 additions & 20 deletions backend/test/routes/change-walkthroughs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { allMigrations } from '../../src/db/migrations'
import { ChangeWalkthroughService } from '../../src/services/change-walkthroughs'
import { createChangeWalkthroughRoutes } from '../../src/routes/change-walkthroughs'
import type { OpenCodeClient } from '../../src/services/opencode/client'
import { stubLoadedModelCatalog } from '../helpers/stub-opencode-client'

const SESSION_ID = 'ses_walkthrough'

Expand All @@ -28,10 +29,11 @@ interface FakeSession {

function createFakeClient(sessions: Record<string, FakeSession>) {
const generateCalls: string[] = []
let reply = MODEL_REPLY
let generateImpl: () => Promise<string> = async () => MODEL_REPLY

const client = {
api: {
...stubLoadedModelCatalog(),
session: {
get: vi.fn(async ({ sessionID }: { sessionID: string }) => {
const config = sessions[sessionID]
Expand Down Expand Up @@ -62,7 +64,7 @@ function createFakeClient(sessions: Record<string, FakeSession>) {
generate: {
text: vi.fn(async (input: { prompt: string }) => {
generateCalls.push(input.prompt)
return { text: reply }
return { text: await generateImpl() }
}),
},
},
Expand All @@ -72,8 +74,8 @@ function createFakeClient(sessions: Record<string, FakeSession>) {
return {
client,
generateCalls,
setReply: (next: string) => {
reply = next
setGenerateImpl: (impl: () => Promise<string>) => {
generateImpl = impl
},
}
}
Expand Down Expand Up @@ -102,10 +104,18 @@ describe('change walkthrough routes', () => {
const res = await app.request(`/change-walkthroughs/${SESSION_ID}`)

expect(res.status).toBe(200)
const body = (await res.json()) as { walkthrough: unknown; currentDiffHash: string | null; stale: boolean }
const body = (await res.json()) as {
walkthrough: unknown
currentDiffHash: string | null
stale: boolean
generating: boolean
error: unknown
}
expect(body.walkthrough).toBeNull()
expect(body.stale).toBe(false)
expect(body.currentDiffHash).toEqual(expect.any(String))
expect(body.generating).toBe(false)
expect(body.error).toBeNull()
})

it('GET returns 404 for a missing session', async () => {
Expand All @@ -115,50 +125,95 @@ describe('change walkthrough routes', () => {
await expect(res.json()).resolves.toMatchObject({ error: 'Session not found' })
})

it('POST creates a walkthrough and GET reads it back', async () => {
it('POST returns 202 while generating, then GET reads the walkthrough back', async () => {
let resolveGenerate: (text: string) => void = () => {}
fake.setGenerateImpl(() => new Promise<string>((resolve) => {
resolveGenerate = resolve
}))

const postRes = await app.request(`/change-walkthroughs/${SESSION_ID}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})

expect(postRes.status).toBe(201)
const created = (await postRes.json()) as { walkthrough: { summary: string; sessionId: string } }
expect(created.walkthrough.summary).toBe('A summary')
expect(postRes.status).toBe(202)
const body = (await postRes.json()) as { generating: boolean; walkthrough: unknown }
expect(body.generating).toBe(true)
expect(body.walkthrough).toBeNull()

resolveGenerate(MODEL_REPLY)

const getRes = await app.request(`/change-walkthroughs/${SESSION_ID}`)
const state = (await getRes.json()) as { walkthrough: { summary: string } }
expect(state.walkthrough.summary).toBe('A summary')
await vi.waitFor(async () => {
const getRes = await app.request(`/change-walkthroughs/${SESSION_ID}`)
const state = (await getRes.json()) as { walkthrough: { summary: string } | null; generating: boolean }
expect(state.generating).toBe(false)
expect(state.walkthrough?.summary).toBe('A summary')
})
})

it('POST accepts an empty body', async () => {
let resolveGenerate: (text: string) => void = () => {}
fake.setGenerateImpl(() => new Promise<string>((resolve) => {
resolveGenerate = resolve
}))

const res = await app.request(`/change-walkthroughs/${SESSION_ID}`, { method: 'POST' })

expect(res.status).toBe(201)
expect(res.status).toBe(202)

resolveGenerate(MODEL_REPLY)
await vi.waitFor(async () => {
const getRes = await app.request(`/change-walkthroughs/${SESSION_ID}`)
const state = (await getRes.json()) as { walkthrough: unknown }
expect(state.walkthrough).not.toBeNull()
})
})

it('POST returns 200 without a second model call when changes are unchanged', async () => {
it('POST returns 200 with the stored walkthrough without a second model call', async () => {
await app.request(`/change-walkthroughs/${SESSION_ID}`, { method: 'POST' })
await vi.waitFor(async () => {
const getRes = await app.request(`/change-walkthroughs/${SESSION_ID}`)
const state = (await getRes.json()) as { walkthrough: unknown }
expect(state.walkthrough).not.toBeNull()
})

const second = await app.request(`/change-walkthroughs/${SESSION_ID}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})

expect(second.status).toBe(200)
const body = (await second.json()) as { walkthrough: { summary: string }; generating: boolean }
expect(body.walkthrough.summary).toBe('A summary')
expect(body.generating).toBe(false)
expect(fake.generateCalls).toHaveLength(1)
})

it('POST regenerates when asked', async () => {
await app.request(`/change-walkthroughs/${SESSION_ID}`, { method: 'POST' })
await vi.waitFor(async () => {
const getRes = await app.request(`/change-walkthroughs/${SESSION_ID}`)
const state = (await getRes.json()) as { walkthrough: unknown }
expect(state.walkthrough).not.toBeNull()
})

let resolveGenerate: (text: string) => void = () => {}
fake.setGenerateImpl(() => new Promise<string>((resolve) => {
resolveGenerate = resolve
}))

const res = await app.request(`/change-walkthroughs/${SESSION_ID}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ regenerate: true }),
})

expect(res.status).toBe(201)
expect(fake.generateCalls).toHaveLength(2)
expect(res.status).toBe(202)

resolveGenerate(MODEL_REPLY)
await vi.waitFor(() => expect(fake.generateCalls).toHaveLength(2))
})

it('POST rejects an invalid body with 400', async () => {
Expand Down Expand Up @@ -192,13 +247,23 @@ describe('change walkthrough routes', () => {
await expect(res.json()).resolves.toMatchObject({ code: 'WALKTHROUGH_NO_TEXT_CHANGES' })
})

it('POST returns 502 with a code when the model response is unparseable', async () => {
fake.setReply('not json')
it('POST records an unparseable model reply that GET surfaces', async () => {
let resolveGenerate: (text: string) => void = () => {}
fake.setGenerateImpl(() => new Promise<string>((resolve) => {
resolveGenerate = resolve
}))

const res = await app.request(`/change-walkthroughs/${SESSION_ID}`, { method: 'POST' })
expect(res.status).toBe(202)

resolveGenerate('not json')

expect(res.status).toBe(502)
await expect(res.json()).resolves.toMatchObject({ code: 'WALKTHROUGH_UNPARSEABLE' })
await vi.waitFor(async () => {
const getRes = await app.request(`/change-walkthroughs/${SESSION_ID}`)
const state = (await getRes.json()) as { generating: boolean; error: { code?: string } | null }
expect(state.generating).toBe(false)
expect(state.error?.code).toBe('WALKTHROUGH_UNPARSEABLE')
})
})

it('POST returns 404 for a missing session', async () => {
Expand Down
Loading