feat: configure supervisor model and story graph use per conversation

Add a supervisor text-model selector using existing connections and a per-conversation story graph switch above knowledge settings. Hide the switch when Supervisor is disabled and enforce conversation preferences at local and remote tool execution boundaries.

Release note: Choose a dedicated supervisor model and control whether each conversation can query existing stories and experiences. Enabling graph access does not start a review or automatically insert context.
This commit is contained in:
mesalogo
2026-10-03 19:16:35 +08:00
parent 492f1d208a
commit 48d7526b7e
47 changed files with 778 additions and 81 deletions
@@ -109,12 +109,14 @@ it('reads Story Graph through the controlled Harness ACP Main proxy in Ask and E
const db = new AssistantDatabase(':memory:'); db.initialize(root)
let enabled = true
const projectId = db.listProjects()[0]!.id
const conversationId = crypto.randomUUID()
db.saveLocalConversations([{ header: { id: conversationId, projectId, title: 'Graph', updatedAt: Date.now() }, messages: [] }])
const gateway = new KnowledgeMcpGateway({} as KnowledgeService, { storyGraphService: {
available: async () => enabled, read: db.readStoryGraph.bind(db)
available: async binding => db.isConversationStoryGraphEnabled(binding.conversationId!), read: db.readStoryGraph.bind(db)
} })
const signal = new AbortController().signal
const token = gateway.grant('harness-graph', [], signal, 'none', undefined, undefined, undefined, undefined, undefined,
{ projectId, runtimeTarget: 'deepseek-harness' })!
{ projectId, conversationId, runtimeTarget: 'deepseek-harness' })!
const inProcess = createInProcessLaunch(root, { stream(options) {
const prompt = latestUserText(options)
const tool = options.tools?.find(tool => tool.name === 'story_graph_search')
@@ -131,7 +133,8 @@ it('reads Story Graph through the controlled Harness ACP Main proxy in Ask and E
try {
for (const mode of ['ask', 'execute', 'disabled'] as const) {
enabled = mode !== 'disabled'
const events = await collect(runtime.run({ requestId: crypto.randomUUID(), conversationId: 'graph', prompt: mode,
db.setConversationStoryGraphEnabled(conversationId, enabled)
const events = await collect(runtime.run({ requestId: crypto.randomUUID(), conversationId, prompt: mode,
workMode: mode === 'execute' ? 'execute' : 'ask', knowledgeCapabilityToken: token }, signal))
expect(events.at(-1)?.type).toBe('done')
expect(JSON.stringify(events)).toContain(enabled ? 'GRAPH_READ' : 'DISABLED')
+1 -1
View File
@@ -3,7 +3,7 @@ import type { DesktopDiagnosticFailureObserver, DesktopMcpDiagnosticMetadata } f
import { isStoryGraphTool, storyGraphToolNames, type StoryGraphToolName } from '../../shared/story-graph-tools'
import type { RuntimeTarget } from '../../shared/capability-contracts'
export type StoryGraphBinding = { projectId?: string; runtimeTarget: RuntimeTarget }
export type StoryGraphBinding = { projectId?: string; conversationId?: string; runtimeTarget: RuntimeTarget }
export type StoryGraphRemoteBinding = {
available(): Promise<boolean>
call(name: StoryGraphToolName, input: unknown, signal: AbortSignal): Promise<Record<string, unknown>>
@@ -14,9 +14,9 @@ function fixture(whenReady: () => Promise<void> = async () => undefined) {
const profile = { id: 'selected', name: 'Selected', protocol: 'openai-chat-completions',
authentication: 'api-key', apiKey: 'secret', baseUrl: 'https://example.com/v1', modelName: 'selected-model' }
const settings = { provider: 'deepseek-harness', modelProfiles: [profile], deepseekHarnessModelProfile: profile }
const conversation = { projectId: 'project', knowledgeLibraryIds: ['library'], workMode: 'ask' }
const conversation = { projectId: 'project', knowledgeLibraryIds: ['library'], workMode: 'ask', storyGraphEnabled: true }
const project = { id: 'project', runtimeSelection: { provider: 'deepseek-harness', profileId: profile.id } }
const application = { magicNotesEnabled: true, localToolEnvironment: { node: { source: 'managed' } as LocalToolRuntimeSelection } }
const application = { heartbeatEnabled: false, magicNotesEnabled: true, localToolEnvironment: { node: { source: 'managed' } as LocalToolRuntimeSelection } }
const gateway = { start: vi.fn(), dispose: vi.fn(), grant: vi.fn(() => 'builtin-token'),
grantCustomMcp: vi.fn(() => 'custom-token'), getEndpoint: () => 'http://127.0.0.1:1234/mcp',
getAvailableToolNames: vi.fn(() => ['knowledge_search', 'obsidian_read_note']) }
@@ -30,11 +30,11 @@ function fixture(whenReady: () => Promise<void> = async () => undefined) {
const stop = vi.spyOn(NativeDshWebClientService.prototype, 'stop').mockImplementation(async id => { handles.delete(id) })
const openExternal = vi.fn(async () => {})
const coordinator = new NativeClientCoordinator({
database: { getConversation: () => conversation, getProject: () => project },
database: { getConversation: (id: string) => ({ ...conversation, id }), getProject: () => project },
settingsStore: { getResolvedSettings: async () => settings },
applicationSettingsStore: { get: async () => application },
capabilities: { getRuntimeSkillContext: async () => ({ packages: [{ directory: '/skills/test', id: 'test', digest: 'one' }] }),
getResolvedMcpServers: async () => [{ id: 'custom' }], getEnabledBuiltinMcpServerIds: async () => ['knowledge-base', 'obsidian', 'goodbuddy-config'],
getResolvedMcpServers: async () => [{ id: 'custom' }], getEnabledBuiltinMcpServerIds: async () => ['knowledge-base', 'obsidian', 'goodbuddy-config', 'story-graph'],
getObsidianSettings: async () => ({}) },
executionSpaceResolver: { resolveProject: () => ({ kind: 'local', rootPath: '/workspace', cacheIdentity: '/workspace' }) },
terminalManager: { closeOwner: vi.fn() }, localEnvironment: { launchEnvironmentProvider: () => process.env, whenReady },
@@ -45,6 +45,17 @@ function fixture(whenReady: () => Promise<void> = async () => undefined) {
}
describe('native client coordinator', () => {
it('binds story graph to its conversation and does not reuse another conversation or disabled configuration', async () => {
const { coordinator, conversation, application, gateway } = fixture()
application.heartbeatEnabled = true
await coordinator.open(1, 'first')
expect(gateway.grant.mock.calls.at(-1)?.at(-1)).toEqual({ projectId: 'project', conversationId: 'first', runtimeTarget: 'deepseek-harness' })
expect(await coordinator.get(1, 'second')).toBeNull()
conversation.storyGraphEnabled = false
expect(await coordinator.get(1, 'first')).toBeNull()
await coordinator.open(1, 'first')
expect(gateway.grant.mock.calls.at(-1)?.at(-1)).toBeUndefined()
})
it('waits for the startup tool environment before launching a native client', async () => {
let ready!: () => void
const { coordinator, start } = fixture(() => new Promise<void>(resolve => { ready = resolve }))
+3 -2
View File
@@ -99,7 +99,7 @@ export class NativeClientCoordinator {
this.options.applicationSettingsStore.get(),
this.options.capabilities.getObsidianSettings()
])
const key = createHash('sha256').update(JSON.stringify({ projectId: project.id, space: space.cacheIdentity,
const key = createHash('sha256').update(JSON.stringify({ projectId: project.id, conversationId, storyGraphEnabled: conversation.storyGraphEnabled !== false, space: space.cacheIdentity,
target: selected.target, profile: selected.target === 'deepseek-harness' ? selected.settings.deepseekHarnessModelProfile
: selected.target === 'continue' ? selected.settings.continueModelProfile : selected.settings.opencodeModelProfile,
workMode, skills, mcpServers, builtin,
@@ -168,7 +168,8 @@ export class NativeClientCoordinator {
builtin.includes('magic-notes') && application.magicNotesEnabled ? access : 'none',
builtin.includes('goodbuddy-config') ? { access, workspacePath: space.rootPath } : undefined,
undefined, undefined, undefined, builtin.includes('obsidian') ? { settings: obsidian, access } : undefined,
builtin.includes('story-graph') && application.heartbeatEnabled ? { projectId: project.id, runtimeTarget: selected.target } : undefined)
builtin.includes('story-graph') && application.heartbeatEnabled && conversation.storyGraphEnabled !== false
? { projectId: project.id, conversationId: conversation.id, runtimeTarget: selected.target } : undefined)
const customToken = workMode === 'execute' ? gateway.grantCustomMcp(requestId, mcpServers, signal) : undefined
const endpoints = [token, customToken].flatMap((value, index) => value ? [{ name: `goodbuddy-${index}`, url: gateway.getEndpoint()!, headers: { Authorization: `Bearer ${value}` },
readOnlyTools: index === 0 && workMode === 'ask' ? gateway.getAvailableToolNames(value) : [] }] : [])
@@ -141,6 +141,23 @@ describe('ApplicationSettingsStore', () => {
await expect(store.update({ supervisionReview: { ...supervisionReview, ...patch } })).rejects.toThrow()
}
})
it.each([11, 12])('persists the supervisor model reference and defaults old settings to app default in version %s', async (version) => {
const { filePath, store } = await createStore()
const legacy = { ...defaultApplicationSettings, version, lastSeenReleaseNotesVersion: null }
delete legacy.supervisorModelProfileId
await writeFile(filePath, JSON.stringify(legacy))
expect((await store.get()).supervisorModelProfileId).toBeNull()
const profileId = '00000000-0000-4000-8000-000000000201'
await store.update({ supervisorModelProfileId: profileId })
await store.update({ checkUpdatesOnStartup: false })
expect((await createApplicationSettingsStore(filePath).get()).supervisorModelProfileId).toBe(profileId)
for (const value of ['', 'missing', 12, { apiKey: 'not-a-profile-reference' }]) {
await expect(store.update({ supervisorModelProfileId: value })).rejects.toThrow()
}
await store.update({ supervisorModelProfileId: null })
expect((await createApplicationSettingsStore(filePath).get()).supervisorModelProfileId).toBeNull()
})
it.each([11, 12])('persists supervision concurrency and defaults missing values in version %s', async (version) => {
const { filePath, store } = await createStore()
const legacy = { ...defaultApplicationSettings, version, lastSeenReleaseNotesVersion: null }
+3
View File
@@ -265,6 +265,7 @@ export const defaultApplicationSettings: ApplicationSettings = {
heartbeatReportTimeoutSeconds: defaultSupervisionTimeoutSeconds,
supervisorOrganizeTimeoutSeconds: defaultSupervisionTimeoutSeconds,
supervisorModelConcurrency: defaultSupervisorModelConcurrency,
supervisorModelProfileId: null,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined',
@@ -545,6 +546,7 @@ export class ApplicationSettingsStore {
heartbeatReportTimeoutSeconds: stored.heartbeatReportTimeoutSeconds ?? defaultSupervisionTimeoutSeconds,
supervisorOrganizeTimeoutSeconds: stored.supervisorOrganizeTimeoutSeconds ?? defaultSupervisionTimeoutSeconds,
supervisorModelConcurrency: stored.supervisorModelConcurrency ?? defaultSupervisorModelConcurrency,
supervisorModelProfileId: stored.supervisorModelProfileId ?? null,
supervisionReview: stored.supervisionReview,
magicNotesShowIncompleteTodoCount:
stored.magicNotesShowIncompleteTodoCount,
@@ -593,6 +595,7 @@ export class ApplicationSettingsStore {
heartbeatReportTimeoutSeconds: next.heartbeatReportTimeoutSeconds ?? defaultSupervisionTimeoutSeconds,
supervisorOrganizeTimeoutSeconds: next.supervisorOrganizeTimeoutSeconds ?? defaultSupervisionTimeoutSeconds,
supervisorModelConcurrency: next.supervisorModelConcurrency ?? defaultSupervisorModelConcurrency,
supervisorModelProfileId: next.supervisorModelProfileId ?? null,
supervisionReview: next.supervisionReview,
magicNotesShowIncompleteTodoCount:
next.magicNotesShowIncompleteTodoCount,
@@ -101,6 +101,27 @@ it('roundtrips canvas assets and keeps todos stable across geometry edits and co
} finally { database.close() }
})
it('persists the story graph switch per conversation without schema migration or stale autosave overrides', async () => {
const database = await createDatabase()
const header = { id: randomUUID(), title: 'Graph preference', updatedAt: 1000, knowledgeRetrievalMode: 'always' as const }
const other = { ...header, id: randomUUID() }
try {
database.saveLocalConversations([{ header, messages: [] }, { header: other, messages: [] }])
expect(database.isConversationStoryGraphEnabled(header.id)).toBe(true)
database.setConversationStoryGraphEnabled(header.id, false)
database.saveLocalConversations([{ header, messages: [] }])
database.close()
database.initialize('C:\\Workspace')
expect(database.getConversation(header.id)).toMatchObject({ storyGraphEnabled: false, knowledgeRetrievalMode: 'always' })
expect(database.isConversationStoryGraphEnabled(header.id)).toBe(false)
expect(database.isConversationStoryGraphEnabled(other.id)).toBe(true)
database.setConversationStoryGraphEnabled(header.id, true)
expect(database.isConversationStoryGraphEnabled(header.id)).toBe(true)
expect(database.isConversationStoryGraphEnabled(randomUUID())).toBe(false)
expect(() => database.setConversationStoryGraphEnabled(randomUUID(), false)).toThrow('对话不存在')
} finally { database.close() }
})
it('persists conversation pins without changing timestamps or allowing autosaves to overwrite them', async () => {
const database = await createDatabase()
const header = { id: randomUUID(), title: 'Pinned conversation', updatedAt: 1000 }
+26 -5
View File
@@ -1234,6 +1234,7 @@ const conversationContextStateSchema = conversationSnapshotSchema.pick({
workMode: true,
knowledgeLibraryIds: true,
knowledgeRetrievalMode: true,
storyGraphEnabled: true,
contextMetrics: true,
contextCompressionState: true
})
@@ -1242,7 +1243,7 @@ function parseConversationContextState(
value: string | null
): Pick<
ConversationSnapshot,
'workMode' | 'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'contextMetrics' | 'contextCompressionState'
'workMode' | 'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'contextMetrics' | 'contextCompressionState'
> {
if (!value) {
return {}
@@ -1260,18 +1261,20 @@ function parseConversationContextState(
function serializeConversationContextState(
conversation: Pick<
ConversationSnapshot,
'workMode' | 'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'contextMetrics' | 'contextCompressionState'
'workMode' | 'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'contextMetrics' | 'contextCompressionState'
>
): string | null {
return conversation.workMode !== undefined ||
conversation.knowledgeLibraryIds !== undefined ||
conversation.knowledgeRetrievalMode !== undefined ||
conversation.storyGraphEnabled !== undefined ||
conversation.contextMetrics ||
conversation.contextCompressionState
? JSON.stringify({
workMode: conversation.workMode,
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
storyGraphEnabled: conversation.storyGraphEnabled,
contextMetrics: conversation.contextMetrics,
contextCompressionState: conversation.contextCompressionState
})
@@ -3194,6 +3197,22 @@ export class AssistantDatabase {
}
}
isConversationStoryGraphEnabled(conversationId: string): boolean {
const row = this.requireDatabase().prepare(
"SELECT context_state_json FROM conversations WHERE id = ? AND status = 'active'"
).get(conversationId) as { context_state_json: string | null } | undefined
return Boolean(row && parseConversationContextState(row.context_state_json).storyGraphEnabled !== false)
}
setConversationStoryGraphEnabled(conversationId: string, enabled: boolean): void {
const result = this.requireDatabase().prepare(
`UPDATE conversations
SET context_state_json = json_set(COALESCE(context_state_json, '{}'), '$.storyGraphEnabled', json(?))
WHERE id = ? AND status = 'active'`
).run(JSON.stringify(enabled), conversationId)
if (result.changes !== 1) throw new Error('对话不存在')
}
/**
* Clears references to deleted model connections in every project and
* conversation so they fall back to the next layer instead of failing.
@@ -3324,7 +3343,7 @@ export class AssistantDatabase {
saveLocalConversations(batch: LocalConversationSaveBatch): void {
const database = this.requireDatabase()
const findConversation = database.prepare(
'SELECT channel FROM conversations WHERE id = ?'
'SELECT channel, context_state_json FROM conversations WHERE id = ?'
)
const insertConversation = database.prepare(
`INSERT INTO conversations
@@ -3376,7 +3395,7 @@ export class AssistantDatabase {
const { header } = save
const existingConversation = findConversation.get(
header.id
) as { channel: ProjectChannel | null } | undefined
) as { channel: ProjectChannel | null; context_state_json: string | null } | undefined
const updatedAt = new Date(header.updatedAt).toISOString()
if (existingConversation?.channel) {
throw new Error('本地对话 ID 与远程对话冲突')
@@ -3386,7 +3405,9 @@ export class AssistantDatabase {
header.projectId ?? null,
serializeRuntimeSelection(header.runtimeSelection),
header.knowledgeRetrievalMode ?? null,
serializeConversationContextState(header),
// The explicit switch owns this preference; a queued older header must not undo it.
serializeConversationContextState({ ...header,
storyGraphEnabled: parseConversationContextState(existingConversation.context_state_json).storyGraphEnabled }),
header.title,
header.branch?.sourceConversationId ?? null,
header.branch?.sourceTitle ?? null,
@@ -4,7 +4,7 @@ import { afterEach, expect, it, vi } from 'vitest'
import type { AgentRuntime } from '../agent/runtime'
import type { SupervisionEvidence, SupervisionRunRequest } from '../../shared/supervision-contracts'
import { AssistantDatabase } from './assistant-database'
import { createProductionSupervisorService } from './supervision-production'
import { createProductionSupervisorService, createProductionSuggestionPhraser } from './supervision-production'
import { SupervisionModelPool } from './supervision-model-pool'
import { supervisionEntitySchema } from '../../shared/supervision-contracts'
@@ -12,6 +12,88 @@ const cleanups: Array<() => void> = []
afterEach(() => { for (const cleanup of cleanups.splice(0)) cleanup() })
const empty = { summary: 'Review', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }
it('keeps one model across extraction, navigation, stories and experiences, and reads the next selection on retry', async () => {
const f = fixture('A'.repeat(1500))
let profileId: string | null = 'first'
const phases: string[] = []
const dispose = vi.fn(async () => {})
const resolve = vi.fn(async (selected?: string | null) => ({
runtimeId: 'model', capability: 'chat', requiresToolApproval: false, supportsToolExecution: false, getStatus: vi.fn(), dispose,
run: async function* (input) {
expect(selected).toBe('first')
profileId = 'next'
let output: unknown
if (input.prompt.includes('You organise the GoodBuddy')) {
phases.push('stories')
output = { stories: [{ key: 'new_1', level: 'feature', name: 'Work' }],
assignments: [{ event: 'e_1', story: 'new_1' }, { event: 'e_2', story: 'new_1' }] }
} else if (input.prompt.includes('You distil reusable experience')) {
phases.push('experiences')
output = { experiences: [], applications: [] }
} else if (input.prompt.includes('These inputs are navigation summaries')) {
phases.push('navigation')
output = { summary: 'Combined', changeDigest: '', openItems: [] }
} else {
phases.push('extraction')
const evidence = JSON.parse(input.prompt.split('BOUNDED EVIDENCE:\n\n')[1]!.split('\n\nReturn only JSON.')[0]!) as SupervisionEvidence[]
output = { ...empty, events: [{ title: 'Decision', description: '', eventType: 'decision',
occurredAt: f.request.timeRange.from, entityIds: [], sourceReferenceIds: [evidence[0]!.id] }] }
}
yield { type: 'text', requestId: input.requestId, delta: JSON.stringify(output) }
yield { type: 'done', requestId: input.requestId }
}
} as AgentRuntime))
const pool = new SupervisionModelPool()
cleanups.push(() => pool.dispose())
const service = createProductionSupervisorService(f.db, async () => ({ supervisorModelProfileId: profileId,
supervisionReview: { pageSize: 10, batchCharacters: 1000, batchMessages: 10, executionSeconds: 300, experienceMinEvents: 2 } }), resolve, pool)
const result = await service.run(f.request)
expect(phases).toEqual(['extraction', 'extraction', 'navigation', 'stories', 'experiences'])
expect(resolve).toHaveBeenCalledExactlyOnceWith('first')
expect(dispose).toHaveBeenCalledOnce()
await service.organizeStoriesFor(result.runId!)
expect(resolve).toHaveBeenLastCalledWith('next')
expect(dispose).toHaveBeenCalledTimes(2)
})
it('releases a failed review runtime and uses the current model when continuing saved work', async () => {
const f = fixture()
let profileId: string | null = 'first'
const dispose = vi.fn(async () => {})
const resolve = vi.fn(async (selected?: string | null) => ({ runtimeId: 'model', capability: 'chat',
requiresToolApproval: false, supportsToolExecution: false, getStatus: vi.fn(), dispose,
run: async function* (input) {
if (selected === 'first') throw new Error('Provider unavailable')
yield { type: 'text', requestId: input.requestId, delta: JSON.stringify(empty) }
yield { type: 'done', requestId: input.requestId }
}
} as AgentRuntime))
const pool = new SupervisionModelPool()
cleanups.push(() => pool.dispose())
const service = createProductionSupervisorService(f.db, async () => ({ supervisorModelProfileId: profileId }), resolve, pool)
await expect(service.run(f.request)).rejects.toThrow('Provider unavailable')
expect(dispose).toHaveBeenCalledOnce()
expect(service.execution().active).toBe(false)
profileId = null
await expect(service.resume(f.db.listSupervisionActivity()[0]!.id)).resolves.toMatchObject({ status: 'completed' })
expect(resolve.mock.calls).toEqual([['first'], [null]])
expect(dispose).toHaveBeenCalledTimes(2)
})
it('uses the supervisor selection for suggestions and disposes its runtime on failure', async () => {
const f = fixture()
const dispose = vi.fn(async () => {})
const resolve = vi.fn(async () => ({ runtimeId: 'model', capability: 'chat', requiresToolApproval: false, supportsToolExecution: false, getStatus: vi.fn(), dispose,
run: async function* (input) { yield { type: 'error', requestId: input.requestId, message: 'Provider unavailable' } }
} as AgentRuntime))
const pool = new SupervisionModelPool()
cleanups.push(() => pool.dispose())
const phrase = createProductionSuggestionPhraser(f.db, async () => ({ supervisorModelProfileId: 'reviewer' }), resolve, pool)
await expect(phrase({ systemInstruction: 'Suggest', outputContract: '{}', candidates: [] })).rejects.toThrow('Provider unavailable')
expect(resolve).toHaveBeenCalledExactlyOnceWith('reviewer')
expect(dispose).toHaveBeenCalledOnce()
})
it('production six-leaf merge failure resumes navigation-only JSON and publishes all retained facts', async () => {
const f = fixture('A'.repeat(765))
const now = Date.parse(f.request.timeRange.from) + 1
+31 -13
View File
@@ -85,7 +85,7 @@ async function runSupervisionModel(dependencies: SupervisionModelDependencies, r
function dependencies(
database: AssistantDatabase,
getSettings: () => Promise<Partial<ApplicationSettings> | undefined>,
resolveRuntime: () => Promise<AgentRuntime>,
resolveRuntime: (profileId?: string | null) => Promise<AgentRuntime>,
pool: SupervisionModelPool,
persistUsage?: (event: RuntimeModelUsageEvent) => void
): SupervisionModelDependencies {
@@ -98,11 +98,16 @@ function dependencies(
export function createProductionSupervisorService(
database: AssistantDatabase,
getSettings: () => Promise<Partial<ApplicationSettings> | undefined>,
resolveRuntime: () => Promise<AgentRuntime>,
resolveRuntime: (profileId?: string | null) => Promise<AgentRuntime>,
pool: SupervisionModelPool,
persistUsage?: (event: RuntimeModelUsageEvent) => void
): SupervisorService {
const model = dependencies(database, getSettings, resolveRuntime, pool, persistUsage)
// The service admits only one execution at a time, including resume and story retry.
let runtime: AgentRuntime | undefined
const model = dependencies(database, getSettings, async () => {
if (!runtime) throw new Error('Supervisor model execution is not active')
return runtime
}, pool, persistUsage)
return new SupervisorService({ collect: async () => { throw new Error('Paged review collector required') } }, {
summarize: async request => runSupervisionModel(model, {
title: '监督者回顾', instructions: '根据有界证据整理监督者回顾',
@@ -122,7 +127,15 @@ export function createProductionSupervisorService(
start: (request, heartbeatRunId) => database.startSupervisionRun(request, heartbeatRunId),
fail: (runId, error) => database.failSupervisionRun(runId, error), noChange: runId => database.noChangeSupervisionRun(runId),
candidates: async request => database.listSupervisionCandidates(request), save: async result => database.saveSupervisionResult(result)
}, { database: () => database.supervisionReviewStore(), configuration: async () => {
}, { database: () => database.supervisionReviewStore(), withExecution: async operation => {
runtime = await resolveRuntime((await getSettings())?.supervisorModelProfileId)
try { return await operation() }
finally {
const completed = runtime
runtime = undefined
await completed.dispose?.()
}
}, configuration: async () => {
const settings = await getSettings()
return { ...supervisionReviewSettingsSchema.parse(settings?.supervisionReview ?? {}), version: 1,
timeoutSeconds: settings?.supervisorOrganizeTimeoutSeconds ?? defaultSupervisionTimeoutSeconds,
@@ -163,16 +176,21 @@ async function assignStoriesStep(model: SupervisionModelDependencies, request: S
export function createProductionSuggestionPhraser(
database: AssistantDatabase,
getSettings: () => Promise<Partial<ApplicationSettings> | undefined>,
resolveRuntime: () => Promise<AgentRuntime>,
resolveRuntime: (profileId?: string | null) => Promise<AgentRuntime>,
pool: SupervisionModelPool,
persistUsage?: (event: RuntimeModelUsageEvent) => void
): SuggestionPhraser {
const model = dependencies(database, getSettings, resolveRuntime, pool, persistUsage)
return async request => runSupervisionModel(model, {
title: '监督者建议', instructions: '根据已发布的图谱变化生成建议',
timeoutMessage: seconds => `监督者建议生成超过 ${seconds} 秒,已停止;回顾结果已保留`,
authorizeTool: async name => { throw new Error(`监督者禁止调用工具: ${name}`) },
prompt: [request.systemInstruction, 'OUTPUT CONTRACT:', request.outputContract,
'CANDIDATES:', JSON.stringify(request.candidates), 'Return only JSON.'].join('\n\n')
})
return async request => {
const runtime = await resolveRuntime((await getSettings())?.supervisorModelProfileId)
const model = dependencies(database, getSettings, async () => runtime, pool, persistUsage)
try {
return await runSupervisionModel(model, {
title: '监督者建议', instructions: '根据已发布的图谱变化生成建议',
timeoutMessage: seconds => `监督者建议生成超过 ${seconds} 秒,已停止;回顾结果已保留`,
authorizeTool: async name => { throw new Error(`监督者禁止调用工具: ${name}`) },
prompt: [request.systemInstruction, 'OUTPUT CONTRACT:', request.outputContract,
'CANDIDATES:', JSON.stringify(request.candidates), 'Return only JSON.'].join('\n\n')
})
} finally { await runtime.dispose?.() }
}
}
+2 -1
View File
@@ -186,6 +186,7 @@ export class SupervisorService {
private readonly review?: {
database: () => SupervisionReviewStore
configuration: () => Promise<ReviewConfiguration>
withExecution?: (operation: () => Promise<StoredSupervisionResult>) => Promise<StoredSupervisionResult>
/** Story assignment over published, still unassigned events of the scope. Runs inside the review slot. */
stories?: (request: SupervisionRunRequest, config: ReviewConfiguration, signal: AbortSignal) => Promise<NonNullable<SupervisionReviewProgress['stories']>>
}
@@ -234,7 +235,7 @@ export class SupervisorService {
await this.active.settled.catch(() => undefined)
}
// Reserve before any asynchronous configuration, collection or resume work.
const active = { settled: Promise.resolve().then(operation) }
const active = { settled: Promise.resolve().then(() => this.review?.withExecution ? this.review.withExecution(operation) : operation()) }
this.active = active
try { return await active.settled }
finally { if (this.active === active) this.active = undefined }
+4 -2
View File
@@ -1057,7 +1057,8 @@ if (hasSingleInstanceLock) {
{
observeFailure: observeDesktopFailure,
storyGraphService: {
available: async ({ runtimeTarget }) => (await applicationSettingsStore.get()).heartbeatEnabled === true &&
available: async ({ runtimeTarget, conversationId }) => (await applicationSettingsStore.get()).heartbeatEnabled === true &&
(!conversationId || startupAssistantDatabase.isConversationStoryGraphEnabled(conversationId)) &&
(await capabilityService.getEnabledBuiltinMcpServerIds(runtimeTarget)).includes('story-graph'),
read: (name, input, projectId, signal) => startupAssistantDatabase.readStoryGraphAsync(name, input, projectId, signal)
},
@@ -1540,7 +1541,8 @@ if (hasSingleInstanceLock) {
createGateway: () => new KnowledgeMcpGateway(startupKnowledgeService, {
observeFailure: observeDesktopFailure,
storyGraphService: {
available: async ({ runtimeTarget }) => (await applicationSettingsStore.get()).heartbeatEnabled === true &&
available: async ({ runtimeTarget, conversationId }) => (await applicationSettingsStore.get()).heartbeatEnabled === true &&
(!conversationId || startupAssistantDatabase.isConversationStoryGraphEnabled(conversationId)) &&
(await capabilityService.getEnabledBuiltinMcpServerIds(runtimeTarget)).includes('story-graph'),
read: (name, input, projectId, signal) => startupAssistantDatabase.readStoryGraphAsync(name, input, projectId, signal)
},
+72 -2
View File
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type {} from '@testing-library/jest-dom/vitest'
import { EventEmitter } from 'node:events'
import { createServer } from 'node:http'
import {
mkdtemp,
mkdir,
@@ -21,6 +22,7 @@ import type {
} from '../shared/assistant-contracts'
import {
browserTabIdSchema,
defaultModelProfileId,
defaultRuntimeSettings,
runtimeSettingsInputSchema,
type AgentEvent,
@@ -4684,6 +4686,16 @@ describe('registerIpcHandlers local conversation persistence', () => {
expect(webContents.send).not.toHaveBeenCalled()
expect(database.getConversation(conversationId)).toEqual(before)
const setStoryGraph = electronMocks.handlers.get(ipcChannels.conversationsSetStoryGraph)!
expect(database.isConversationStoryGraphEnabled(conversationId)).toBe(true)
for (const enabled of [false, true]) {
setStoryGraph(event, { conversationId, enabled })
expect(database.isConversationStoryGraphEnabled(conversationId)).toBe(enabled)
expect(database.getConversation(conversationId)).toEqual({ ...before, storyGraphEnabled: enabled })
}
expect(() => setStoryGraph(event, { conversationId, enabled: 'false' })).toThrow()
expect(() => setStoryGraph({ ...event, sender: {} }, { conversationId, enabled: false })).toThrow()
expect(electronMocks.handlers.get(ipcChannels.conversationsDeleteLocal)!(event, conversationId)).toBe(true)
expect(collect).toHaveBeenCalledOnce()
expect(contextManager.cancelUnavailableImport).toHaveBeenCalledOnce()
@@ -5509,6 +5521,8 @@ describe('registerIpcHandlers agent terminal state', () => {
runtimeSelection: { provider: 'opencode' },
messages: []
})),
isConversationStoryGraphEnabled: vi.fn(() => true),
setConversationStoryGraphEnabled: vi.fn(),
getProject: vi.fn<
(projectId: string) => Record<string, unknown>
>((projectId) => ({
@@ -5559,6 +5573,8 @@ describe('registerIpcHandlers agent terminal state', () => {
}
const getResolvedSettings = vi.fn(
async (): Promise<Record<string, unknown>> => ({
modelProfiles: [{ id: defaultModelProfileId, name: 'Default', modelName: 'qwen3', protocol: 'openai-chat-completions', authentication: 'none' }],
defaultModelProfileId,
toolApproval,
subagentSmartRoutingEnabled: smartRoutingEnabled
})
@@ -5634,6 +5650,7 @@ describe('registerIpcHandlers agent terminal state', () => {
args[45] = nativeClientCoordinator
args[41] = nativeTerminalManager
if (applicationSettingsStore) args[15] = applicationSettingsStore
runtimeFactoryMocks.createModelProfileRuntime.mockReturnValue(runtime)
const dispose = registerIpcHandlers(...args)
return {
approvalBroker,
@@ -6396,6 +6413,57 @@ describe('registerIpcHandlers agent terminal state', () => {
} finally { await harness.dispose(); database.close() }
})
it('routes supervisor model selection through the real direct runtime to a saved connection', async () => {
const calls: Array<{ model: string; tools?: unknown; temperature?: number }> = []
const server = createServer(async (request, response) => {
let body = ''
for await (const chunk of request) body += String(chunk)
calls.push(JSON.parse(body))
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.end(`data: ${JSON.stringify({ choices: [{ delta: { content: JSON.stringify({ summary: 'Connection verified', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }) }, finish_reason: null }] })}\n\ndata: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }] })}\n\ndata: [DONE]\n\n`)
})
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address() as { port: number }
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
const now = Date.now()
database.saveLocalConversations([{ header: { id: crypto.randomUUID(), projectId: database.listProjects()[0]!.id, title: 'Review', updatedAt: now },
messages: [{ id: crypto.randomUUID(), role: 'user', state: 'complete', content: 'A saved decision', createdAt: now }] }])
const harness = createHarness({ runtimeId: 'opencode', capability: 'chat', run: vi.fn() }, undefined, 'always', undefined, false,
undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database)
const actual = await vi.importActual<typeof import('./agent/create-runtime')>('./agent/create-runtime')
runtimeFactoryMocks.createModelProfileRuntime.mockImplementation(actual.createModelProfileRuntime)
const profile = { id: crypto.randomUUID(), name: 'Review connection',
baseUrl: `http://127.0.0.1:${address.port}/v1`, modelName: 'review-selected', protocol: 'openai-chat-completions', authentication: 'none',
requestHeaders: {}, requestBody: { temperature: 0.25 } }
const fallback = { ...profile, id: crypto.randomUUID(), modelName: 'app-default' }
harness.getResolvedSettings.mockResolvedValue({ ...defaultRuntimeSettings, provider: 'opencode', modelProfiles: [profile, fallback], defaultModelProfileId: fallback.id })
const handler = electronMocks.handlers.get(ipcChannels.supervisionRun)!
const input = { trigger: 'manual', reanalyze: true, scope: { kind: 'global' }, timeRange: {
from: new Date(now - 1).toISOString(), to: new Date(now + 1).toISOString()
} }
try {
for (const supervisorModelProfileId of [profile.id, null]) {
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true, supervisorModelProfileId })
await expect(handler(trustedEvent(harness.webContents), input)).resolves.toMatchObject({ status: 'completed' })
}
expect(calls.map(call => call.model)).toEqual(['review-selected', 'app-default'])
expect(calls.every(call => !call.tools && call.temperature === 0.25)).toBe(true)
for (const invalid of [undefined, { ...profile, protocol: 'openai-images-generations' }, { ...profile, authentication: 'api-key', apiKey: undefined }]) {
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true, supervisorModelProfileId: profile.id })
harness.getResolvedSettings.mockResolvedValue({ modelProfiles: invalid ? [invalid, fallback] : [fallback], defaultModelProfileId: fallback.id })
await expect(handler(trustedEvent(harness.webContents), input)).rejects.toThrow(/不可用|API Key/)
}
expect(calls).toHaveLength(2)
} finally {
await harness.dispose()
database.close()
server.closeAllConnections()
await new Promise<void>(resolve => server.close(() => resolve()))
runtimeFactoryMocks.createModelProfileRuntime.mockReset()
}
})
it.each(['global', 'projects'] as const)('collects supervision within the exact UI timeRange before limits (%s)', async (kind) => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-09-22T08:00:00.000Z'))
@@ -9549,16 +9617,18 @@ describe('registerIpcHandlers agent terminal state', () => {
})
try {
const projectId = '00000000-0000-4000-8000-000000000088'
for (const enabled of [true, false]) {
for (const [enabled, conversationEnabled] of [[true, true], [false, true], [true, false]]) {
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: enabled })
harness.assistantDatabase.isConversationStoryGraphEnabled.mockReturnValue(conversationEnabled!)
const requestId = crypto.randomUUID()
await harness.handler!(trustedEvent(harness.webContents), { requestId, projectId, conversationId: 'graph-grant', prompt: 'Continue work', workMode: 'ask', knowledgeLibraryIds: [] })
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(requestId, 'completed'))
}
expect(gateway.grant).toHaveBeenCalledExactlyOnceWith(expect.any(String), [], expect.any(AbortSignal), 'none',
undefined, undefined, undefined, undefined, undefined, { projectId, runtimeTarget: runtimeId })
undefined, undefined, undefined, undefined, undefined, { projectId, conversationId: 'graph-grant', runtimeTarget: runtimeId })
expect(received[0]).toMatchObject({ knowledgeCapabilityToken: 'graph-capability', storyGraphBinding: binding })
expect(received[1]).not.toHaveProperty('storyGraphBinding')
expect(received[2]).not.toHaveProperty('storyGraphBinding')
expect(gateway.bindRemoteStoryGraph).toHaveBeenCalledOnce()
} finally { await harness.dispose() }
})
+29 -6
View File
@@ -137,6 +137,7 @@ import {
conversationListRequestSchema,
conversationSearchRequestSchema,
conversationSetPinnedSchema,
conversationSetStoryGraphSchema,
type ConversationSnapshot,
localConversationSaveBatchSchema,
memoryCreateSchema,
@@ -2228,8 +2229,9 @@ export function registerIpcHandlers(
)
: []
const notesCapability = await grantScopedDataCapability({
storyGraph: requestRuntimeTarget && (await applicationSettingsStore?.get())?.heartbeatEnabled
? { runtimeTarget: requestRuntimeTarget, projectId: schedule.projectId ?? undefined } : undefined,
storyGraph: requestRuntimeTarget && (await applicationSettingsStore?.get())?.heartbeatEnabled &&
assistantDatabase.isConversationStoryGraphEnabled(runtimeConversationId)
? { runtimeTarget: requestRuntimeTarget, projectId: schedule.projectId ?? undefined, conversationId: runtimeConversationId } : undefined,
obsidian: enabledBuiltinMcpServers.includes('obsidian')
? {
settings: await capabilityService.getObsidianSettings(),
@@ -3761,8 +3763,9 @@ export function registerIpcHandlers(
const imageSaveAvailable = Boolean(imageToolBinding?.save && normalizedWorkMode === 'execute' &&
await imageToolBinding.describeSave?.())
const scopedCapability = await grantScopedDataCapability({
storyGraph: selectedRuntimeTarget && applicationSettings?.heartbeatEnabled
? { runtimeTarget: selectedRuntimeTarget, projectId: enrichedRequest.projectId } : undefined,
storyGraph: selectedRuntimeTarget && applicationSettings?.heartbeatEnabled &&
assistantDatabase.isConversationStoryGraphEnabled(enrichedRequest.conversationId)
? { runtimeTarget: selectedRuntimeTarget, projectId: enrichedRequest.projectId, conversationId: enrichedRequest.conversationId } : undefined,
obsidian: enabledBuiltinMcpServers.includes('obsidian')
? {
settings: await capabilityService.getObsidianSettings(),
@@ -6383,11 +6386,31 @@ export function registerIpcHandlers(
publishConversationChange()
}
)
registerHandler(
ipcChannels.conversationsSetStoryGraph,
(event, input: unknown) => {
assertTrustedSender(event, window)
const { conversationId, enabled } = conversationSetStoryGraphSchema.parse(input)
assistantDatabase.setConversationStoryGraphEnabled(conversationId, enabled)
publishConversationChange()
}
)
const resolveSupervisorRuntime = async (profileId?: string | null): Promise<AgentRuntime> => {
const settings = await settingsStore.getResolvedSettings()
const profile = settings.modelProfiles.find(candidate => candidate.id === (profileId ?? settings.defaultModelProfileId))
if (!profile || !isAgentRuntimeModelProtocol(profile.protocol)) {
throw new Error('监督者所选文本模型不存在或已不可用,请在监督者设置中重新选择模型。')
}
if (profile.authentication === 'api-key' && !profile.apiKey) {
throw new Error(`监督者模型连接“${profile.name}”未配置 API Key`)
}
return createModelProfileRuntime(settings.workspacePath, settings, profile)
}
const supervisorService = createProductionSupervisorService(assistantDatabase,
async () => applicationSettingsStore?.get(), () => resolveRequestRuntime({ workMode: 'ask' }), supervisionModelPool,
async () => applicationSettingsStore?.get(), resolveSupervisorRuntime, supervisionModelPool,
persistModelUsage)
const suggestionPhraser = createProductionSuggestionPhraser(assistantDatabase,
async () => applicationSettingsStore?.get(), () => resolveRequestRuntime({ workMode: 'ask' }), supervisionModelPool,
async () => applicationSettingsStore?.get(), resolveSupervisorRuntime, supervisionModelPool,
persistModelUsage)
registerHandler(