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
2 changes: 1 addition & 1 deletion backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ app.route('/api/auth-info', createAuthInfoRoutes(auth, db))
app.route('/api/health', createHealthRoutes(db, openCodeSupervisor))

app.route('/api/mcp-oauth-proxy', createMcpOauthProxyRoutes(openCodeClient, requireAuth))
app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, sessionPermissionModeService, repoWorkspaces, gitAuthService, sessionGoalService, multiRunService))
app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, sessionPermissionModeService, repoWorkspaces, gitAuthService, sessionGoalService, multiRunService, changeWalkthroughService))
app.route('/api/opencode-proxy', createOpenCodeProxyRoutes(db, settingsService, upgradeWebSocket))

const protectedApi = new Hono()
Expand Down
4 changes: 4 additions & 0 deletions backend/src/routes/internal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@ import { createInternalGitCredentialsRoutes } from './git-credentials'
import { createInternalSandboxRoutes } from './sandbox'
import { createSessionGoalRoutes } from '../session-goals'
import { createMultiRunRoutes } from '../multi-runs'
import { createChangeWalkthroughRoutes } from '../change-walkthroughs'
import type { SessionPermissionModeService } from '../../services/session-permission-modes'
import type { RepoWorkspaceService } from '../../services/repo-workspace'
import type { GitAuthService } from '../../services/git-auth'
import type { SessionGoalService } from '../../services/session-goals'
import type { MultiRunService } from '../../services/multi-runs'
import type { ChangeWalkthroughService } from '../../services/change-walkthroughs'

export function createInternalRoutes(
db: Database,
Expand All @@ -36,6 +38,7 @@ export function createInternalRoutes(
gitAuthService: GitAuthService,
sessionGoals: SessionGoalService,
multiRuns: MultiRunService,
changeWalkthroughService: ChangeWalkthroughService,
) {
const app = new Hono()
app.use('/*', createInternalTokenMiddleware(db))
Expand All @@ -54,6 +57,7 @@ export function createInternalRoutes(
app.route('/opencode-workspaces', createInternalOpenCodeWorkspacesRoutes(db))
app.route('/sessions', createInternalSessionRoutes(db, openCodeClient, permissionModes, repoWorkspaces))
app.route('/assistant', createInternalAssistantRoutes(openCodeClient))
app.route('/change-walkthroughs', createChangeWalkthroughRoutes(changeWalkthroughService))
app.route('/git-credentials', createInternalGitCredentialsRoutes(db))
app.route('/sandbox', createInternalSandboxRoutes(db))
return app
Expand Down
3 changes: 1 addition & 2 deletions backend/src/services/assistant-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,11 @@ import {
fileExists,
ensureDirectoryExists,
} from './file-operations'
import { ASSISTANT_NOTIFICATION_LIMITS, AssistantSettingsPatchSchema, OpenCodeConfigSchema, DEFAULT_KEYBOARD_SHORTCUTS, DEFAULT_LEADER_KEY } from '@opencode-manager/shared/schemas'
import { ASSISTANT_NOTIFICATION_LIMITS, AssistantSettingsPatchSchema, OpenCodeConfigSchema, DEFAULT_KEYBOARD_SHORTCUTS, DEFAULT_LEADER_KEY, MANAGER_TOOL_NAME } from '@opencode-manager/shared/schemas'
import { MANAGER_COLOR_THEME_ID, OPENCODE_THEMES } from '@opencode-manager/shared/themes'
import { ASSISTANT_REPO_ID, ASSISTANT_REPO_PATH, ASSISTANT_OPENCODE_DIR_NAME } from '@opencode-manager/shared/utils'
import { getAssistantModePath, getReposPath } from '@opencode-manager/shared/config/env'
import type { Database } from 'bun:sqlite'
import { MANAGER_TOOL_NAME } from './opencode-manager-tool-plugin'
import { ensureAssistantRepo } from '../db/queries'


Expand Down
13 changes: 12 additions & 1 deletion backend/src/services/change-walkthroughs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1297,14 +1297,25 @@ export class ChangeWalkthroughService {
}
}

/**
* Resolves the model for one generation, waiting once for OpenCode's lazily-loaded global catalog so an explicit
* walkthrough model is registered before the first call. A failed wait is swallowed because generation proceeds and
* lets OpenCode surface its own error; the wait is per generation, never per stop.
*/
private async resolveWalkthroughModel(session: SessionInfo): Promise<ModelRef> {
const configured = this.settingsService.getSettings().preferences.walkthroughModel?.trim()
const parsed = configured ? parseOpenCodeModelRef(configured) : undefined
const preferred = parsed ?? session.model
if (preferred) {
try {
await resolveOpenCodeModel(this.openCodeClient, getOpenCodeGlobalConfigPath(), {
preferredModel: formatOpenCodeModelRef(preferred),
})
} catch {
return preferred
}
return preferred
}

let resolved
try {
resolved = await resolveOpenCodeModel(this.openCodeClient, getOpenCodeGlobalConfigPath())
Expand Down
44 changes: 34 additions & 10 deletions backend/src/services/opencode-manager-tool-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { z } from 'zod'
import { ASSISTANT_NOTIFICATION_LIMITS } from '@opencode-manager/shared/schemas'

export const MANAGER_TOOL_NAME = 'ocm'
import { ASSISTANT_NOTIFICATION_LIMITS, MANAGER_TOOL_ACTIONS, MANAGER_TOOL_NAME, WalkthroughSourceSchema } from '@opencode-manager/shared/schemas'

const MANAGER_TOOL_REQUEST_TIMEOUT_MS = 60000

Expand Down Expand Up @@ -40,8 +38,6 @@ export const MANAGER_TOOL_ALLOWED_ROUTES = [

const MANAGER_TOOL_ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const

const MANAGER_TOOL_ACTION_NAMES = ['send_notification', 'request'] as const

const ManagerToolNotificationParamsSchema = z
.object({
title: z.string().min(1).max(ASSISTANT_NOTIFICATION_LIMITS.TITLE_MAX).describe('The notification title.'),
Expand All @@ -62,9 +58,17 @@ const ManagerToolRequestParamsSchema = z
.strict()
.describe('Call an allow-listed OpenCode Manager internal API route.')

const MANAGER_TOOL_ACTION_PARAMS_SCHEMAS: Record<(typeof MANAGER_TOOL_ACTION_NAMES)[number], z.ZodType> = {
const ManagerToolWalkthroughParamsSchema = z
.object({
source: WalkthroughSourceSchema.optional().describe('The change source to walk through. Defaults to the changes in this session.'),
})
.strict()
.describe("Request the change Walkthrough for this session, generating it if needed. It opens in the user's Manager window when this session is open on desktop; otherwise the user can open it from this tool call.")

const MANAGER_TOOL_ACTION_PARAMS_SCHEMAS: Record<(typeof MANAGER_TOOL_ACTIONS)[number], z.ZodType> = {
send_notification: ManagerToolNotificationParamsSchema,
request: ManagerToolRequestParamsSchema,
open_walkthrough: ManagerToolWalkthroughParamsSchema,
}

export function parseAllowedRoute(route: string): { method: string; path: string } {
Expand Down Expand Up @@ -92,9 +96,13 @@ function buildManagerToolInputJsonSchema(): Record<string, unknown> {
const jsonSchema: Record<string, unknown> = z.toJSONSchema(
z
.object({
action: z.enum(MANAGER_TOOL_ACTION_NAMES).describe('The OpenCode Manager action to perform.'),
action: z.enum(MANAGER_TOOL_ACTIONS).describe('The OpenCode Manager action to perform.'),
params: z
.union([MANAGER_TOOL_ACTION_PARAMS_SCHEMAS.send_notification, MANAGER_TOOL_ACTION_PARAMS_SCHEMAS.request])
.union([
MANAGER_TOOL_ACTION_PARAMS_SCHEMAS.send_notification,
MANAGER_TOOL_ACTION_PARAMS_SCHEMAS.request,
MANAGER_TOOL_ACTION_PARAMS_SCHEMAS.open_walkthrough,
])
.describe('The parameters for the chosen action.'),
})
.strict(),
Expand All @@ -104,7 +112,7 @@ function buildManagerToolInputJsonSchema(): Record<string, unknown> {
}

function buildManagerToolActionRequiredKeys(): Record<string, string[]> {
return Object.fromEntries(MANAGER_TOOL_ACTION_NAMES.map((name) => [name, requiredKeysOf(MANAGER_TOOL_ACTION_PARAMS_SCHEMAS[name])]))
return Object.fromEntries(MANAGER_TOOL_ACTIONS.map((name) => [name, requiredKeysOf(MANAGER_TOOL_ACTION_PARAMS_SCHEMAS[name])]))
}

function buildManagerToolDescription(): string {
Expand All @@ -114,6 +122,7 @@ function buildManagerToolDescription(): string {
'Actions:',
'- send_notification: send a push notification to every device the user has registered.',
'- request: call an allow-listed internal API route to read and manage settings, the OpenCode configuration file, repos (list, inspect, clone from a git URL with POST /repos and body { repoUrl, branch?, directoryName? }), OpenCode workspaces, sessions (list, create, follow up, read the latest reply, fork), and schedules.',
"- open_walkthrough: request the change Walkthrough for the current session, generating it if needed. It opens in the user's Manager window when this session is open on desktop; otherwise the user opens it from this tool call. Use when the user asks to walk through, review or explain the changes.",
'Allowed request routes:',
]
.concat(MANAGER_TOOL_ALLOWED_ROUTES.map((route) => `- ${route}`))
Expand All @@ -129,7 +138,7 @@ var ALLOWED_MATCHERS = ${JSON.stringify(buildRouteMatchers())}.map(function (mat
return { method: matcher.method, pattern: new RegExp(matcher.source) }
})

var ACTION_NAMES = ${JSON.stringify(MANAGER_TOOL_ACTION_NAMES)}
var ACTION_NAMES = ${JSON.stringify(MANAGER_TOOL_ACTIONS)}

var ACTION_REQUIRED_KEYS = ${JSON.stringify(buildManagerToolActionRequiredKeys())}

Expand Down Expand Up @@ -221,6 +230,21 @@ var ACTIONS = {
return text || 'The request succeeded with an empty response body.'
},
},
open_walkthrough: {
run: async function (params, context) {
var body = params.source === undefined ? undefined : { source: params.source }
var state = await postInternalApi('/change-walkthroughs/' + encodeURIComponent(context.sessionID), body, context.signal)
var hint = ' It opens in the Manager window when this session is open on desktop; otherwise the user can open it from this tool call.'
if (state.generating === true) {
return 'Requested the walkthrough; it is generating.' + hint
}
var stops = state.walkthrough && Array.isArray(state.walkthrough.stops) ? state.walkthrough.stops.length : 0
if (state.walkthrough) {
return 'Requested the walkthrough (' + stops + ' stops).' + hint
}
return 'Requested the walkthrough.' + hint
},
},
}

function assertParams(actionName, params) {
Expand Down
2 changes: 2 additions & 0 deletions backend/test/helpers/internal-test-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ type InternalTestAppOverrides = Partial<{
gitAuthService: CreateInternalRoutesParams[7]
sessionGoals: CreateInternalRoutesParams[8]
multiRuns: CreateInternalRoutesParams[9]
changeWalkthroughService: CreateInternalRoutesParams[10]
}>

export function createInternalTestApp(
Expand All @@ -30,5 +31,6 @@ export function createInternalTestApp(
overrides.gitAuthService ?? ({} as CreateInternalRoutesParams[7]),
overrides.sessionGoals ?? ({} as CreateInternalRoutesParams[8]),
overrides.multiRuns ?? ({} as CreateInternalRoutesParams[9]),
overrides.changeWalkthroughService ?? ({} as CreateInternalRoutesParams[10]),
)
}
2 changes: 1 addition & 1 deletion backend/test/helpers/opencode-plugin-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export type GeneratedTool = {
name: string
description?: string
input?: unknown
execute?: (input: unknown, context: { signal: AbortSignal }) => Promise<unknown>
execute?: (input: unknown, context: { signal: AbortSignal; sessionID?: string }) => Promise<unknown>
[key: string]: unknown
}

Expand Down
12 changes: 7 additions & 5 deletions backend/test/helpers/stub-opencode-client.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { vi } from 'vitest'
import type { OpenCodeApi } from '@opencode-manager/shared/opencode'
import type { ModelInfo, 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 }
export function stubLoadedModelCatalog(models: ModelInfo[] = [
{ providerID: 'openai', id: 'gpt-5-mini', enabled: true } as ModelInfo,
]) {
const defaultModel = models[0] ?? null
return {
config: { get: vi.fn(async () => []) },
model: {
list: vi.fn(async () => ({ data: [model] })),
default: vi.fn(async () => ({ data: model })),
list: vi.fn(async () => ({ data: models })),
default: vi.fn(async () => ({ data: defaultModel })),
},
}
}
Expand Down
84 changes: 84 additions & 0 deletions backend/test/routes/internal-change-walkthroughs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { Hono } from 'hono'
import { Database } from 'bun:sqlite'
import { allMigrations } from '../../src/db/migrations'
import { getOrCreateInternalToken } from '../../src/services/internal-token'
import { migrate } from '../../src/db/migration-runner'
import type { ChangeWalkthroughService } from '../../src/services/change-walkthroughs'
import { createInternalTestApp } from '../helpers/internal-test-app'

const SESSION_ID = 'ses_walkthrough'

describe('internal/change-walkthroughs routes', () => {
let db: Database
let app: Hono
let token: string
let startGeneration: ReturnType<typeof vi.fn>
let getState: ReturnType<typeof vi.fn>

beforeEach(() => {
db = new Database(':memory:')
migrate(db, allMigrations)

startGeneration = vi.fn(async () => ({
walkthrough: null,
currentDiffHash: 'hash',
stale: false,
generating: true,
error: null,
}))
getState = vi.fn(async () => ({
walkthrough: null,
currentDiffHash: 'hash',
stale: false,
generating: false,
error: null,
}))

app = new Hono()
app.route(
'/api/internal',
createInternalTestApp(db, { changeWalkthroughService: { startGeneration, getState } as unknown as ChangeWalkthroughService }),
)
token = getOrCreateInternalToken(db)
})

it('POST /api/internal/change-walkthroughs/:id returns 401 without a bearer token', async () => {
const res = await app.request(`/api/internal/change-walkthroughs/${SESSION_ID}`, { method: 'POST' })

expect(res.status).toBe(401)
expect(startGeneration).not.toHaveBeenCalled()
})

it('POST /api/internal/change-walkthroughs/:id starts generation with the session id and source', async () => {
const res = await app.request(`/api/internal/change-walkthroughs/${SESSION_ID}`, {
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify({ source: { kind: 'staged' } }),
})

expect(res.status).toBe(202)
expect(startGeneration).toHaveBeenCalledTimes(1)
expect(startGeneration).toHaveBeenCalledWith(SESSION_ID, { source: { kind: 'staged' } })
await expect(res.json()).resolves.toMatchObject({ generating: true })
})

it('POST /api/internal/change-walkthroughs/:id accepts an empty body', async () => {
const res = await app.request(`/api/internal/change-walkthroughs/${SESSION_ID}`, {
method: 'POST',
headers: { authorization: `Bearer ${token}` },
})

expect(res.status).toBe(202)
expect(startGeneration).toHaveBeenCalledWith(SESSION_ID, {})
})

it('GET /api/internal/change-walkthroughs/:id reads the requested source', async () => {
const res = await app.request(`/api/internal/change-walkthroughs/${SESSION_ID}?source=staged`, {
headers: { authorization: `Bearer ${token}` },
})

expect(res.status).toBe(200)
expect(getState).toHaveBeenCalledWith(SESSION_ID, { kind: 'staged' }, undefined)
})
})
37 changes: 33 additions & 4 deletions backend/test/services/change-walkthroughs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { Database } from 'bun:sqlite'
import type { FileDiffInfo, ModelRef, SessionInfo, SessionMessageInfo } from '@opencode-manager/shared/opencode'
import type { FileDiffInfo, ModelInfo, ModelRef, SessionInfo, SessionMessageInfo } from '@opencode-manager/shared/opencode'
import {
MECHANICAL_TEXT_BUDGET,
WALKTHROUGH_DIFF_MAX_CHARS,
Expand Down Expand Up @@ -152,7 +152,11 @@ function createFakeClient(sessions: Record<string, FakeSession>) {

const client = {
api: {
...stubLoadedModelCatalog(),
...stubLoadedModelCatalog([
{ providerID: 'openai', id: 'gpt-5-mini', enabled: true },
{ providerID: 'anthropic', id: 'claude-sonnet-4', enabled: true },
{ providerID: 'openai', id: 'gpt-5', enabled: true },
] as ModelInfo[]),
session: {
get: vi.fn(async ({ sessionID }: { sessionID: string }) => {
const config = sessions[sessionID]
Expand Down Expand Up @@ -1169,13 +1173,14 @@ describe('ChangeWalkthroughService', () => {
it('returns the stored walkthrough without a model call when changes are unchanged', async () => {
fake.setGenerateImpl(async () => modelReply([{ title: 'A', explanation: 'x', hunkIds: THREE_HUNK_IDS }]))
const first = await service.generate(SESSION_ID, {})
const modelListCallsAfterFirst = vi.mocked(fake.client.api.model.list).mock.calls.length

const second = await service.generate(SESSION_ID, {})

expect(second.created).toBe(false)
expect(second.walkthrough).toEqual(first.walkthrough)
expect(fake.generateCalls).toHaveLength(1)
expect(vi.mocked(fake.client.api.model.list)).toHaveBeenCalledTimes(1)
expect(vi.mocked(fake.client.api.model.list)).toHaveBeenCalledTimes(modelListCallsAfterFirst)
})

it('calls the model again when regenerate is set', async () => {
Expand Down Expand Up @@ -1233,14 +1238,37 @@ describe('ChangeWalkthroughService', () => {
expect(fake.generateModels[0]).toEqual({ providerID: 'anthropic', id: 'claude-sonnet-4' })
})

it('resolves the default model once per generation', async () => {
it('reuses the resolved default model for every stop', async () => {
sessions[SESSION_ID]!.changes = LARGE_CHANGES
fake.setGenerateImpl((prompt) =>
Promise.resolve(prompt.includes(PLAN_MARKER) ? LARGE_PLAN_REPLY : EXPLANATION_REPLY),
)

await service.generate(SESSION_ID, {})

expect(fake.generateModels).toHaveLength(4)
expect(
fake.generateModels.every((model) => model?.providerID === 'openai' && model.id === 'gpt-5-mini'),
).toBe(true)
})

it('waits for an explicit model once per generation, not per stop', async () => {
sessions[SESSION_ID]!.changes = LARGE_CHANGES
sessions[SESSION_ID]!.info = {
id: SESSION_ID,
title: 'Title',
model: { providerID: 'openai', id: 'gpt-5-mini' },
} as SessionInfo
fake.setGenerateImpl((prompt) =>
Promise.resolve(prompt.includes(PLAN_MARKER) ? LARGE_PLAN_REPLY : EXPLANATION_REPLY),
)

await service.generate(SESSION_ID, {})

expect(fake.generateModels).toHaveLength(4)
expect(
fake.generateModels.every((model) => model?.providerID === 'openai' && model.id === 'gpt-5-mini'),
).toBe(true)
expect(vi.mocked(fake.client.api.model.list)).toHaveBeenCalledTimes(1)
})

Expand Down Expand Up @@ -1762,6 +1790,7 @@ describe('ChangeWalkthroughService', () => {
expect(state.generating).toBe(true)
expect(state.error).toBeNull()

await vi.waitFor(() => expect(fake.generateCalls.length).toBeGreaterThan(2))
resolveGenerate(coveringReply)
await vi.waitFor(async () => {
expect((await service.getState(SESSION_ID)).generating).toBe(false)
Expand Down
Loading