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) : [] }] : [])