feat: expand multi-runtime workflows for 0.10.0

GoodBuddy previously exposed Runtime capabilities, MCP assignments, plugin controls, context compaction, usage reporting, and task-completion behavior through incomplete or inconsistent paths. This release unifies managed OpenCode, Continue, and DeepSeek Harness controls; adds DSH plugin and image workflows; strengthens MCP and Runtime lifecycle bounds; fixes Windows notification activation; validates cross-architecture packages; and presents the approved bilingual four-section release notes.

The DSH plugin marketplace remains a default-off preview whose trusted third-party code runs with current-user permissions. Ask remains read-only, Execute keeps approval controls, and context compaction may use the selected model without deleting GoodBuddy chat history.

Release note: GoodBuddy 0.10.0 重点完善多 Runtime 工作流,统一 OpenCode、Continue 与 DeepSeek Harness 的能力、MCP、插件和上下文管理,并提升长对话、多会话与任务通知的连贯性。
This commit is contained in:
mesalogo
2026-08-17 12:57:12 +08:00
parent f5ee9b2198
commit cbbe30896e
50 changed files with 4077 additions and 605 deletions
+149 -1
View File
@@ -27,7 +27,10 @@ vi.mock('./runtime-discovery', () => ({
detectRuntimeBinary: mocks.detectRuntimeBinary
}))
import { ContinueAgentRuntime } from './continue-runtime'
import {
buildContinuePrompt,
ContinueAgentRuntime
} from './continue-runtime'
function createRuntime(): ContinueAgentRuntime {
return new ContinueAgentRuntime({
@@ -618,6 +621,151 @@ describe('ContinueAgentRuntime', () => {
expect(prompt).not.toContain('old secret turn')
})
it('keeps a persisted summary when its covered prefix rolls out of the bounded history window', () => {
const history = Array.from({ length: 500 }, (_, index) => ({
role:
index % 2 === 0
? ('user' as const)
: ('assistant' as const),
content: `recent message ${index}`
}))
const prompt = buildContinuePrompt({
requestId: randomUUID(),
conversationId: 'evicted-summary-conversation',
prompt: 'continue',
history,
historyMessageIds: history.map(() => randomUUID()),
contextCompressionState: {
coveredHistoryDigest: createHash('sha256')
.update(
JSON.stringify([
{ role: 'user', content: 'evicted question' },
{ role: 'assistant', content: 'evicted answer' }
])
)
.digest('hex'),
coveredMessageCount: 2,
coveredFromMessageId: randomUUID(),
coveredThroughMessageId: randomUUID(),
summary: 'persisted evicted facts'
}
})
expect(prompt).toContain('persisted evicted facts')
expect(prompt).toContain('recent message 499')
expect(prompt).not.toContain('evicted question')
})
it('keeps a persisted summary when filtered messages shorten the bounded history window', () => {
const history = Array.from({ length: 499 }, (_, index) => ({
role:
index % 2 === 0
? ('user' as const)
: ('assistant' as const),
content: `filtered recent message ${index}`
}))
const prompt = buildContinuePrompt({
requestId: randomUUID(),
conversationId: 'filtered-evicted-summary-conversation',
prompt: 'continue',
history,
historyMessageIds: history.map(() => randomUUID()),
contextCompressionState: {
coveredHistoryDigest: createHash('sha256')
.update(
JSON.stringify([
{ role: 'user', content: 'evicted question' },
{ role: 'assistant', content: 'evicted answer' }
])
)
.digest('hex'),
coveredMessageCount: 2,
coveredFromMessageId: randomUUID(),
coveredThroughMessageId: randomUUID(),
summary: 'persisted facts after filtering'
}
})
expect(prompt).toContain('persisted facts after filtering')
expect(prompt).toContain('filtered recent message 498')
expect(prompt).not.toContain('evicted question')
})
it('keeps a persisted summary when only its covered start rolls out of the history window', () => {
const coveredThroughMessageId = randomUUID()
const history = [
{
role: 'assistant' as const,
content: 'covered answer still at window start'
},
...Array.from({ length: 499 }, (_, index) => ({
role:
index % 2 === 0
? ('user' as const)
: ('assistant' as const),
content: `later message ${index}`
}))
]
const prompt = buildContinuePrompt({
requestId: randomUUID(),
conversationId: 'partially-evicted-summary-conversation',
prompt: 'continue',
history,
historyMessageIds: [
coveredThroughMessageId,
...history.slice(1).map(() => randomUUID())
],
contextCompressionState: {
coveredHistoryDigest: createHash('sha256')
.update(
JSON.stringify([
{ role: 'user', content: 'evicted covered question' },
history[0]
])
)
.digest('hex'),
coveredMessageCount: 2,
coveredFromMessageId: randomUUID(),
coveredThroughMessageId,
summary: 'persisted partially evicted facts'
}
})
expect(prompt).toContain('persisted partially evicted facts')
expect(prompt).toContain('later message 498')
expect(prompt).not.toContain('covered answer still at window start')
})
it('rejects a persisted summary that contradicts the current bounded history window', () => {
const coveredThroughMessageId = randomUUID()
const history = Array.from({ length: 500 }, (_, index) => ({
role:
index % 2 === 0
? ('user' as const)
: ('assistant' as const),
content: `conflicting message ${index}`
}))
const historyMessageIds = history.map(() => randomUUID())
historyMessageIds[10] = coveredThroughMessageId
const prompt = buildContinuePrompt({
requestId: randomUUID(),
conversationId: 'conflicting-summary-conversation',
prompt: 'continue',
history,
historyMessageIds,
contextCompressionState: {
coveredHistoryDigest: '0'.repeat(64),
coveredMessageCount: 2,
coveredFromMessageId: randomUUID(),
coveredThroughMessageId,
summary: 'contradictory summary must not appear'
}
})
expect(prompt).toContain('conflicting message 499')
expect(prompt).not.toContain('contradictory summary must not appear')
})
it('falls back to bounded raw history when a persisted summary is stale', async () => {
const runtime = createRuntime()
for await (const _event of runtime.run(
+57 -24
View File
@@ -110,41 +110,74 @@ function flattenContinueSegment(value: string): string {
.trim()
}
function hasValidCompressionPrefix(
function getCurrentCompressionPrefixLength(
request: AgentExecutionRequest
): boolean {
): number | undefined {
const state = request.contextCompressionState
const history = request.history
if (
!state ||
!history ||
state.coveredMessageCount <= 0 ||
state.coveredMessageCount > history.length
state.coveredMessageCount <= 0
) {
return false
}
const coveredHistory = history.slice(0, state.coveredMessageCount)
if (
createHash('sha256')
.update(JSON.stringify(coveredHistory))
.digest('hex') !== state.coveredHistoryDigest
) {
return false
return undefined
}
const ids = request.historyMessageIds
if (
(state.coveredFromMessageId || state.coveredThroughMessageId) &&
(!ids || ids.length !== history.length)
) {
return false
return undefined
}
return (
(!state.coveredFromMessageId ||
ids?.[0] === state.coveredFromMessageId) &&
(!state.coveredThroughMessageId ||
ids?.[state.coveredMessageCount - 1] ===
state.coveredThroughMessageId)
if (state.coveredMessageCount <= history.length) {
const coveredHistory = history.slice(
0,
state.coveredMessageCount
)
const digestMatches =
createHash('sha256')
.update(JSON.stringify(coveredHistory))
.digest('hex') === state.coveredHistoryDigest
const boundariesMatch =
(!state.coveredFromMessageId ||
ids?.[0] === state.coveredFromMessageId) &&
(!state.coveredThroughMessageId ||
ids?.[state.coveredMessageCount - 1] ===
state.coveredThroughMessageId)
if (digestMatches && boundariesMatch) {
return state.coveredMessageCount
}
}
if (
!ids ||
!state.coveredFromMessageId ||
!state.coveredThroughMessageId
) {
return undefined
}
const coveredFromIndex = ids.indexOf(
state.coveredFromMessageId
)
const coveredThroughIndex = ids.indexOf(
state.coveredThroughMessageId
)
if (
coveredFromIndex === -1 &&
coveredThroughIndex >= 0 &&
coveredThroughIndex < state.coveredMessageCount - 1
) {
return coveredThroughIndex + 1
}
if (
coveredFromIndex === -1 &&
coveredThroughIndex === -1
) {
return 0
}
return undefined
}
export function buildContinuePrompt(
@@ -176,7 +209,9 @@ export function buildContinuePrompt(
'Answer the CURRENT USER REQUEST now.'
].join(' | ')
if (hasValidCompressionPrefix(request)) {
const compressionPrefixLength =
getCurrentCompressionPrefixLength(request)
if (compressionPrefixLength !== undefined) {
const state = request.contextCompressionState!
const summaryEnvelope = {
role: 'user' as const,
@@ -207,9 +242,7 @@ export function buildContinuePrompt(
}
if (compose(summaryPair).length <= MAX_CONTINUE_PROMPT_CHARACTERS) {
const retained = [...summaryPair]
const recent = request.history!.slice(
state.coveredMessageCount
)
const recent = request.history!.slice(compressionPrefixLength)
for (const message of recent.slice(-18).reverse()) {
const candidate = [
...summaryPair,
+170 -163
View File
@@ -1005,174 +1005,181 @@ describe('DeepSeek Harness real ACP control-plane E2E', () => {
mkdir(dshHome),
mkdir(installation)
])
const entry = {
id: 'dsh-plugin-greet-live',
package: {
name: 'dsh-plugin-greet',
version: '0.1.0'
},
displayName: 'dsh-plugin-greet',
description: 'Reviewed minimal live DSH plugin fixture.'
}
const npmCliPath = process.env.GOODBUDDY_DSH_NPM_CLI
? resolve(process.env.GOODBUDDY_DSH_NPM_CLI)
: resolve(
'node_modules',
'npm',
'bin',
'npm-cli.js'
)
const nodeExecutablePath =
process.env.GOODBUDDY_DSH_NODE_EXECUTABLE
? resolve(process.env.GOODBUDDY_DSH_NODE_EXECUTABLE)
: undefined
const installer = new DshNpmExtensionInstaller({
dshHome,
npmCliPath,
...(nodeExecutablePath ? { nodeExecutablePath } : {})
})
const installed = await installer.install({
entry,
destinationDirectory: installation
})
const observedRequests: GenerateOptions[] = []
const inProcess = createInProcessLaunch(
dshHome,
undefined,
(options) => observedRequests.push(options)
)
const runtime = new DeepSeekHarnessRuntime({
defaultWorkspace: workspace,
baseUrl: liveBaseUrl,
model: liveModel,
launch: (options) => inProcess.launch(options),
credentialRefs: {
[CREDENTIAL_REF]: liveApiKey
},
extensionPackages: [
{
id: entry.id,
entrypoint: join(
installation,
...installed.entrypoint.split('/')
),
configuration: {}
}
],
initializationTimeoutMs: 20_000,
promptTimeoutMs: 120_000,
shutdownTimeoutMs: 5_000
})
let installer: DshNpmExtensionInstaller | undefined
try {
const askEvents = await collect(
runtime.run(
{
requestId: 'request-live-plugin-ask',
conversationId: 'live-plugin-ask',
prompt:
'DSH_ASK_PLUGIN_PROBE: attempt to call greet exactly once with name GoodBuddyAsk. The runtime must reject it. After the tool result, reply with DSH_ASK_PLUGIN_BLOCKED.',
workMode: 'ask'
},
new AbortController().signal
)
)
const askRequests = observedRequests.filter((options) =>
latestUserText(options).includes(
'DSH_ASK_PLUGIN_PROBE'
)
)
expect(askRequests.length).toBeGreaterThan(0)
expect(
askRequests.flatMap(
(options) =>
options.tools?.map((tool) => tool.name) ?? []
)
).toContain('greet')
expect(askEvents).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'tool',
name: 'greet',
state: 'pending'
}),
expect.objectContaining({
type: 'tool',
state: 'failed',
output: expect.stringContaining(
'Ask 模式不允许执行非只读工具'
)
}),
expect.objectContaining({ type: 'done' })
])
)
expect(
askEvents.some(
(event) =>
event.type === 'tool' &&
event.state === 'completed'
)
).toBe(false)
expect(
askEvents
.flatMap((event) =>
event.type === 'text' ? [event.delta] : []
const entry = {
id: 'dsh-plugin-greet-live',
package: {
name: 'dsh-plugin-greet',
version: '0.2.0'
},
displayName: 'dsh-plugin-greet',
description: 'Reviewed minimal live DSH plugin fixture.'
}
const npmCliPath = process.env.GOODBUDDY_DSH_NPM_CLI
? resolve(process.env.GOODBUDDY_DSH_NPM_CLI)
: resolve(
'node_modules',
'npm',
'bin',
'npm-cli.js'
)
.join('')
).toContain('DSH_ASK_PLUGIN_BLOCKED')
const nodeExecutablePath =
process.env.GOODBUDDY_DSH_NODE_EXECUTABLE
? resolve(
process.env.GOODBUDDY_DSH_NODE_EXECUTABLE
)
: undefined
installer = new DshNpmExtensionInstaller({
dshHome,
npmCliPath,
...(nodeExecutablePath ? { nodeExecutablePath } : {})
})
const installed = await installer.install({
entry,
destinationDirectory: installation
})
const observedRequests: GenerateOptions[] = []
const inProcess = createInProcessLaunch(
dshHome,
undefined,
(options) => observedRequests.push(options)
)
const runtime = new DeepSeekHarnessRuntime({
defaultWorkspace: workspace,
baseUrl: liveBaseUrl,
model: liveModel,
launch: (options) => inProcess.launch(options),
credentialRefs: {
[CREDENTIAL_REF]: liveApiKey
},
extensionPackages: [
{
id: entry.id,
entrypoint: join(
installation,
...installed.entrypoint.split('/')
),
configuration: {}
}
],
initializationTimeoutMs: 20_000,
promptTimeoutMs: 120_000,
shutdownTimeoutMs: 5_000
})
const executeEvents = await collect(
runtime.run(
{
requestId: 'request-live-plugin-execute',
conversationId: 'live-plugin-execute',
prompt:
'DSH_EXECUTE_PLUGIN_PROBE: call greet exactly once with name GoodBuddyLive. After its result, reply with DSH_EXECUTE_PLUGIN_OK and the exact greeting.',
workMode: 'execute'
},
new AbortController().signal
)
)
const executeRequests = observedRequests.filter((options) =>
latestUserText(options).includes(
'DSH_EXECUTE_PLUGIN_PROBE'
)
)
expect(executeRequests.length).toBeGreaterThan(0)
expect(
executeRequests.some((options) =>
options.tools?.some((tool) => tool.name === 'greet')
)
).toBe(true)
expect(executeEvents).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'tool',
name: 'greet',
state: 'pending'
}),
expect.objectContaining({
type: 'tool',
state: 'completed',
output: expect.stringContaining(
'Hello, GoodBuddyLive!'
)
}),
expect.objectContaining({ type: 'done' })
])
)
expect(
executeEvents
.flatMap((event) =>
event.type === 'text' ? [event.delta] : []
try {
const askEvents = await collect(
runtime.run(
{
requestId: 'request-live-plugin-ask',
conversationId: 'live-plugin-ask',
prompt:
'DSH_ASK_PLUGIN_PROBE: attempt to call greet exactly once with name GoodBuddyAsk. The runtime must reject it. After the tool result, reply with DSH_ASK_PLUGIN_BLOCKED.',
workMode: 'ask'
},
new AbortController().signal
)
.join('')
).toContain('DSH_EXECUTE_PLUGIN_OK')
)
const askRequests = observedRequests.filter((options) =>
latestUserText(options).includes(
'DSH_ASK_PLUGIN_PROBE'
)
)
expect(askRequests.length).toBeGreaterThan(0)
expect(
askRequests.flatMap(
(options) =>
options.tools?.map((tool) => tool.name) ?? []
)
).toContain('greet')
expect(askEvents).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'tool',
name: 'greet',
state: 'pending'
}),
expect.objectContaining({
type: 'tool',
state: 'failed',
output: expect.stringContaining(
'Ask 模式不允许执行非只读工具'
)
}),
expect.objectContaining({ type: 'done' })
])
)
expect(
askEvents.some(
(event) =>
event.type === 'tool' &&
event.state === 'completed'
)
).toBe(false)
expect(
askEvents
.flatMap((event) =>
event.type === 'text' ? [event.delta] : []
)
.join('')
).toContain('DSH_ASK_PLUGIN_BLOCKED')
const executeEvents = await collect(
runtime.run(
{
requestId: 'request-live-plugin-execute',
conversationId: 'live-plugin-execute',
prompt:
'DSH_EXECUTE_PLUGIN_PROBE: call greet exactly once with name GoodBuddyLive. After its result, reply with DSH_EXECUTE_PLUGIN_OK and the exact greeting.',
workMode: 'execute'
},
new AbortController().signal
)
)
const executeRequests = observedRequests.filter((options) =>
latestUserText(options).includes(
'DSH_EXECUTE_PLUGIN_PROBE'
)
)
expect(executeRequests.length).toBeGreaterThan(0)
expect(
executeRequests.some((options) =>
options.tools?.some((tool) => tool.name === 'greet')
)
).toBe(true)
expect(executeEvents).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'tool',
name: 'greet',
state: 'pending'
}),
expect.objectContaining({
type: 'tool',
state: 'completed',
output: expect.stringContaining(
'Hello, GoodBuddyLive!'
)
}),
expect.objectContaining({ type: 'done' })
])
)
expect(
executeEvents
.flatMap((event) =>
event.type === 'text' ? [event.delta] : []
)
.join('')
).toContain('DSH_EXECUTE_PLUGIN_OK')
} finally {
await Promise.allSettled([
runtime.dispose(),
...inProcess.hosts.map((host) => host.dispose())
])
}
} finally {
await runtime.dispose()
await Promise.allSettled(
inProcess.hosts.map((host) => host.dispose())
)
await installer?.dispose().catch(() => undefined)
await rm(root, { recursive: true, force: true })
}
},
@@ -14,6 +14,42 @@ export const DEEPSEEK_HARNESS_CREDENTIAL_REF =
'GOODBUDDY_HARNESS_MODEL_API_KEY'
export const DEEPSEEK_HARNESS_MAX_FRAME_BYTES =
8 * 1024 * 1024
export const DEEPSEEK_HARNESS_EXTENSION_ACTIVATION_TIMEOUT_MS =
5_000
export const DEEPSEEK_HARNESS_EXTENSION_DISPOSAL_TIMEOUT_MS =
1_000
export const DEEPSEEK_HARNESS_TOTAL_EXTENSION_ACTIVATION_TIMEOUT_MS =
90_000
const DEEPSEEK_HARNESS_HOST_STARTUP_OVERHEAD_MS = 10_000
const DEEPSEEK_HARNESS_STARTUP_FAILURE_CLEANUP_MS = 2_000
export function deepSeekHarnessStartupBudget(
extensionCount: number
): {
hostTimeoutMs: number
mainTimeoutMs: number
} {
const boundedExtensionCount = Math.max(
0,
Math.floor(extensionCount)
)
const extensionSequenceMs = Math.min(
DEEPSEEK_HARNESS_TOTAL_EXTENSION_ACTIVATION_TIMEOUT_MS +
DEEPSEEK_HARNESS_EXTENSION_DISPOSAL_TIMEOUT_MS,
boundedExtensionCount *
(DEEPSEEK_HARNESS_EXTENSION_ACTIVATION_TIMEOUT_MS +
DEEPSEEK_HARNESS_EXTENSION_DISPOSAL_TIMEOUT_MS)
)
const hostTimeoutMs =
DEEPSEEK_HARNESS_HOST_STARTUP_OVERHEAD_MS +
extensionSequenceMs
return {
hostTimeoutMs,
mainTimeoutMs:
hostTimeoutMs +
DEEPSEEK_HARNESS_STARTUP_FAILURE_CLEANUP_MS
}
}
const skillPackageSchema = z
.object({
@@ -15,6 +15,13 @@ function extension(
}
}
function blockEventLoop(durationMs: number): void {
const deadline = Date.now() + durationMs
while (Date.now() <= deadline) {
// Deliberately model finite synchronous CommonJS/plugin startup work.
}
}
describe('DeepSeek Harness extension loader', () => {
it('loads named Cordis plugin exports and keeps working extensions active', async () => {
const ctx = new Context()
@@ -157,4 +164,91 @@ describe('DeepSeek Harness extension loader', () => {
expect(apply).not.toHaveBeenCalled()
await ctx.fiber.dispose()
})
it('rejects a synchronous import that returns after its budget', async () => {
const ctx = new Context()
const apply = vi.fn()
const result = await loadControlledHarnessExtensions(
ctx,
[extension('slow-import')],
{
activationTimeoutMs: 10,
importModule: async () => {
blockEventLoop(25)
return { apply }
}
}
)
expect(result).toEqual({
loadedIds: [],
failedIds: ['slow-import'],
failures: [
{
id: 'slow-import',
message:
'DeepSeek Harness extension activation timed out'
}
]
})
expect(apply).not.toHaveBeenCalled()
await ctx.fiber.dispose()
})
it('rejects over-budget synchronous apply and loads the next extension', async () => {
const ctx = new Context()
const laterApply = vi.fn()
const result = await loadControlledHarnessExtensions(
ctx,
[extension('slow-apply'), extension('later')],
{
activationTimeoutMs: 10,
totalActivationTimeoutMs: 100,
importModule: async (url) =>
url.includes('slow-apply')
? {
apply() {
blockEventLoop(25)
}
}
: { apply: laterApply }
}
)
expect(result.loadedIds).toEqual(['later'])
expect(result.failedIds).toEqual(['slow-apply'])
expect(result.failures[0]?.message).toBe(
'DeepSeek Harness extension activation timed out'
)
expect(laterApply).toHaveBeenCalledOnce()
await ctx.fiber.dispose()
})
it('times out asynchronous activation and disposes its effects', async () => {
const ctx = new Context()
const cleanup = vi.fn()
const result = await loadControlledHarnessExtensions(
ctx,
[extension('async-slow')],
{
activationTimeoutMs: 10,
importModule: async () => ({
apply(pluginContext: Context) {
pluginContext.effect(() => cleanup)
return new Promise<void>((resolve) =>
setTimeout(resolve, 30)
)
}
})
}
)
expect(result.failedIds).toEqual(['async-slow'])
expect(result.failures[0]?.message).toContain('timed out')
expect(cleanup).toHaveBeenCalledOnce()
await ctx.fiber.dispose()
})
})
@@ -1,10 +1,14 @@
import type { Context, Fiber, Plugin } from '@deepseek-ai/cordis'
import { createRequire } from 'node:module'
import { fileURLToPath, pathToFileURL } from 'node:url'
import {
DEEPSEEK_HARNESS_EXTENSION_ACTIVATION_TIMEOUT_MS,
DEEPSEEK_HARNESS_EXTENSION_DISPOSAL_TIMEOUT_MS,
DEEPSEEK_HARNESS_TOTAL_EXTENSION_ACTIVATION_TIMEOUT_MS
} from './deepseek-harness-control-protocol'
const DEFAULT_ACTIVATION_TIMEOUT_MS = 5_000
const DEFAULT_TOTAL_ACTIVATION_TIMEOUT_MS = 90_000
const DISPOSAL_TIMEOUT_MS = 1_000
const ACTIVATION_TIMEOUT_MESSAGE =
'DeepSeek Harness extension activation timed out'
export type ControlledHarnessExtensionPackage = {
id: string
@@ -117,16 +121,18 @@ export async function loadControlledHarnessExtensions(
const failedIds: string[] = []
const failures: Array<{ id: string; message: string }> = []
const activationTimeoutMs =
options.activationTimeoutMs ?? DEFAULT_ACTIVATION_TIMEOUT_MS
options.activationTimeoutMs ??
DEEPSEEK_HARNESS_EXTENSION_ACTIVATION_TIMEOUT_MS
const deadline =
Date.now() +
(options.totalActivationTimeoutMs ??
DEFAULT_TOTAL_ACTIVATION_TIMEOUT_MS)
DEEPSEEK_HARNESS_TOTAL_EXTENSION_ACTIVATION_TIMEOUT_MS)
const importModule = options.importModule ?? defaultImportModule
for (const extension of extensions) {
let fiber: (Fiber & PromiseLike<Fiber>) | undefined
let acceptActivation = true
let activationDeadline: number | undefined
try {
const remainingMs = deadline - Date.now()
if (remainingMs <= 0) {
@@ -134,32 +140,54 @@ export async function loadControlledHarnessExtensions(
'DeepSeek Harness extension startup deadline exceeded'
)
}
const extensionBudgetMs = Math.max(
1,
Math.min(activationTimeoutMs, remainingMs)
)
activationDeadline = Date.now() + extensionBudgetMs
const rejectLateSynchronousWork = (): void => {
if (Date.now() > activationDeadline!) {
acceptActivation = false
throw new Error(ACTIVATION_TIMEOUT_MESSAGE)
}
}
const activation = (async () => {
const module = await importModule(
pathToFileURL(extension.entrypoint).href
)
rejectLateSynchronousWork()
if (!acceptActivation) {
throw new Error(ACTIVATION_TIMEOUT_MESSAGE)
}
const plugin = resolvePlugin(module)
fiber = ctx.plugin(plugin, extension.configuration)
// A timer cannot run while CommonJS evaluation or a plugin's
// synchronous apply body owns this event loop. Re-check elapsed
// wall time immediately after those calls return so finite
// over-budget work is never reported as successfully activated.
rejectLateSynchronousWork()
await Promise.resolve(fiber)
rejectLateSynchronousWork()
})()
await withTimeout(
(async () => {
const module = await importModule(
pathToFileURL(extension.entrypoint).href
)
if (!acceptActivation) {
throw new Error(
'DeepSeek Harness extension activation timed out'
)
}
const plugin = resolvePlugin(module)
fiber = ctx.plugin(plugin, extension.configuration)
await Promise.resolve(fiber)
})(),
Math.max(1, Math.min(activationTimeoutMs, remainingMs)),
'DeepSeek Harness extension activation timed out',
activation,
Math.max(1, activationDeadline - Date.now()),
ACTIVATION_TIMEOUT_MESSAGE,
() => {
acceptActivation = false
}
)
loadedIds.push(extension.id)
} catch (error) {
const failure =
activationDeadline !== undefined &&
Date.now() > activationDeadline
? new Error(ACTIVATION_TIMEOUT_MESSAGE)
: error
if (fiber) {
await withTimeout(
fiber.dispose(),
DISPOSAL_TIMEOUT_MS,
DEEPSEEK_HARNESS_EXTENSION_DISPOSAL_TIMEOUT_MS,
'DeepSeek Harness extension disposal timed out'
).catch(() => undefined)
}
@@ -167,8 +195,8 @@ export async function loadControlledHarnessExtensions(
failures.push({
id: extension.id,
message:
error instanceof Error && error.message.trim()
? error.message.slice(0, 1_000)
failure instanceof Error && failure.message.trim()
? failure.message.slice(0, 1_000)
: 'DeepSeek Harness extension failed to start'
})
}
+131 -3
View File
@@ -6,6 +6,7 @@ import {
type ModelToolDefinition,
type ModelToolProviderLike
} from './model-tool-provider'
import { deepSeekHarnessStartupBudget } from './deepseek-harness-control-protocol'
import type {
ResolvedMcpServer
} from '../capabilities/capability-service'
@@ -47,6 +48,19 @@ function setup(
maxRequestOutputCharacters?: number
supportsImageInput?: boolean
advertisedImageInput?: boolean
initializationTimeoutMs?: number
useDefaultInitializationTimeout?: boolean
launchDelayMs?: number
extensionPackages?: Array<{
id: string
entrypoint: string
configuration: Record<string, unknown>
}>
launch?: (
options: Parameters<
ConstructorParameters<typeof DeepSeekHarnessRuntime>[0]['launch']
>[0]
) => Promise<DeepSeekHarnessChild>
} = {}
) {
const exit = deferred<{
@@ -215,7 +229,17 @@ function setup(
ClientSideConnection,
ndJsonStream: vi.fn(() => ({ stream: true }))
} as unknown as DeepSeekHarnessAcpSdk
const launch = vi.fn(async () => child)
const launch = vi.fn(
options.launch ??
(async () => {
if (options.launchDelayMs !== undefined) {
await new Promise<void>((resolve) =>
setTimeout(resolve, options.launchDelayMs)
)
}
return child
})
)
const runtime = new DeepSeekHarnessRuntime({
defaultWorkspace: 'C:\\workspace',
baseUrl: 'https://api.deepseek.com',
@@ -223,7 +247,12 @@ function setup(
supportsImageInput: options.supportsImageInput,
launch,
loadAcpSdk: async () => sdk,
initializationTimeoutMs: 100,
...(options.useDefaultInitializationTimeout
? {}
: {
initializationTimeoutMs:
options.initializationTimeoutMs ?? 100
}),
promptTimeoutMs: options.promptTimeoutMs ?? 100,
shutdownTimeoutMs: 10,
maxStderrBytes: 16,
@@ -231,7 +260,8 @@ function setup(
maxRequestOutputCharacters:
options.maxRequestOutputCharacters,
toolProvider: options.toolProvider,
skillPackages: options.skillPackages
skillPackages: options.skillPackages,
extensionPackages: options.extensionPackages
})
const emit = async (
sessionId: string,
@@ -409,6 +439,104 @@ function toolProvider(
}
describe('DeepSeekHarnessRuntime', () => {
it('includes bounded failed-extension cleanup in the startup budget', () => {
expect(deepSeekHarnessStartupBudget(11)).toEqual({
hostTimeoutMs: 76_000,
mainTimeoutMs: 78_000
})
expect(deepSeekHarnessStartupBudget(64)).toEqual({
hostTimeoutMs: 101_000,
mainTimeoutMs: 103_000
})
})
it('expands the default launcher deadline for enabled extensions', async () => {
vi.useFakeTimers()
try {
const harness = setup({
useDefaultInitializationTimeout: true,
launchDelayMs: 10_001,
extensionPackages: [
{
id: 'slow-one',
entrypoint: 'C:\\extensions\\slow-one.js',
configuration: {}
},
{
id: 'slow-two',
entrypoint: 'C:\\extensions\\slow-two.js',
configuration: {}
}
]
})
const status = harness.runtime.getStatus()
await vi.advanceTimersByTimeAsync(10_001)
await expect(status).resolves.toMatchObject({
available: true
})
expect(harness.child.terminate).not.toHaveBeenCalled()
const disposal = harness.runtime.dispose()
await vi.advanceTimersByTimeAsync(10)
await disposal
} finally {
vi.useRealTimers()
}
})
it('keeps the default no-extension launcher deadline bounded', async () => {
vi.useFakeTimers()
try {
let launchSignal: AbortSignal | undefined
const harness = setup({
useDefaultInitializationTimeout: true,
launch: (options) => {
launchSignal = options.signal
return new Promise<DeepSeekHarnessChild>(
() => undefined
)
}
})
const status = harness.runtime.getStatus()
await vi.advanceTimersByTimeAsync(12_000)
await expect(status).resolves.toMatchObject({
available: false,
detail: 'DeepSeek Harness 启动超时'
})
expect(launchSignal?.aborted).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('aborts a pending launch and terminates a child returned after disposal', async () => {
const launch = deferred<DeepSeekHarnessChild>()
let launchSignal: AbortSignal | undefined
const harness = setup({
launch: (options) => {
launchSignal = options.signal
return launch.promise
}
})
const status = harness.runtime.getStatus()
await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce())
await harness.runtime.dispose()
expect(launchSignal?.aborted).toBe(true)
launch.resolve(harness.child)
await expect(status).resolves.toMatchObject({
available: false,
detail: 'DeepSeek Harness Runtime 已关闭'
})
expect(harness.child.terminate).toHaveBeenCalledOnce()
})
it('surfaces bounded internal Harness details from ACP errors', () => {
expect(
harnessPromptError(
+32 -1
View File
@@ -34,6 +34,7 @@ import {
GOODBUDDY_TOOLS_CALL,
GOODBUDDY_TOOLS_LIST
} from './deepseek-harness-protocol'
import { deepSeekHarnessStartupBudget } from './deepseek-harness-control-protocol'
const ACP_PACKAGE_NAME = '@agentclientprotocol/sdk'
const DEFAULT_INITIALIZATION_TIMEOUT_MS = 10_000
@@ -185,6 +186,11 @@ export type DeepSeekHarnessRuntimeOptions = {
launch: (
options: DeepSeekHarnessLaunchOptions
) => Promise<DeepSeekHarnessChild>
/**
* Explicit hard timeout for each initialization operation, including the
* complete launcher call. When omitted, launcher startup is expanded from
* the enabled extension count while later ACP operations retain 10 seconds.
*/
initializationTimeoutMs?: number
promptTimeoutMs?: number
shutdownTimeoutMs?: number
@@ -450,6 +456,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
readonly supportsScopedDataTools = false
private state?: HarnessState
private initialization?: Promise<HarnessState>
private launchController?: AbortController
private disposed = false
private fatalError?: Error
private stderrBytes = 0
@@ -470,6 +477,15 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
)
}
private get launchTimeoutMs(): number {
return (
this.options.initializationTimeoutMs ??
deepSeekHarnessStartupBudget(
this.options.extensionPackages?.length ?? 0
).mainTimeoutMs
)
}
private get promptTimeoutMs(): number {
return this.options.promptTimeoutMs ?? DEFAULT_PROMPT_TIMEOUT_MS
}
@@ -806,6 +822,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
throw new Error('DeepSeek Harness Runtime 已关闭')
}
const launchController = new AbortController()
this.launchController = launchController
let child: DeepSeekHarnessChild | undefined
try {
child = await withTimeout(
@@ -822,9 +839,12 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
skillPackages: this.options.skillPackages ?? [],
extensionPackages: this.options.extensionPackages ?? []
}),
this.initializationTimeoutMs,
this.launchTimeoutMs,
'启动'
)
if (this.disposed) {
throw new Error('DeepSeek Harness Runtime 已关闭')
}
const sdk = await (this.options.loadAcpSdk ?? defaultLoadAcpSdk)()
let agent: AcpAgent | undefined
const connection = new sdk.ClientSideConnection(
@@ -1076,6 +1096,9 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
...stateWithoutCapabilities,
capabilities
}
if (this.disposed) {
throw new Error('DeepSeek Harness Runtime 已关闭')
}
this.state = state
return state
} catch (error) {
@@ -1084,6 +1107,10 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
await this.terminate(child)
}
throw error
} finally {
if (this.launchController === launchController) {
this.launchController = undefined
}
}
}
@@ -1611,6 +1638,10 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
return
}
this.disposed = true
this.launchController?.abort(
new Error('DeepSeek Harness Runtime 已关闭')
)
this.launchController = undefined
const state = this.state
this.state = undefined
this.initialization = undefined
@@ -15,6 +15,7 @@ import {
DEEPSEEK_HARNESS_CREDENTIAL_REF,
DEEPSEEK_HARNESS_HOST_VERSION,
DEEPSEEK_HARNESS_MAX_FRAME_BYTES,
deepSeekHarnessStartupBudget,
parseHarnessControlMessage,
type DeepSeekHarnessControlMessage as HarnessControlMessage
} from './deepseek-harness-control-protocol'
@@ -53,6 +54,11 @@ export type DeepSeekHarnessUtilityLauncherOptions = {
onExtensionStartupFailures?: (
extensionIds: readonly string[]
) => Promise<void>
/**
* Explicit hard Host-handshake deadline. Callers that also set the Runtime
* initialization timeout must leave enough additional time for startup
* failure persistence.
*/
startupTimeoutMs?: number
}
@@ -174,10 +180,9 @@ export function createDeepSeekHarnessUtilityLauncher(
}
const startupTimeoutMs =
launcherOptions.startupTimeoutMs ??
Math.min(
120_000,
10_000 + canonicalExtensionPackages.length * 5_000
)
deepSeekHarnessStartupBudget(
canonicalExtensionPackages.length
).hostTimeoutMs
let timer: ReturnType<typeof setTimeout> | undefined
let onAbort: (() => void) | undefined
try {
@@ -19,12 +19,22 @@ describe.skipIf(!enabled)('DSH marketplace live E2E', () => {
const userDataPath = await mkdtemp(
join(tmpdir(), 'goodbuddy-dsh-marketplace-live-')
)
let installer: DshNpmExtensionInstaller | undefined
let host:
| Awaited<
ReturnType<typeof startControlledDeepSeekHarnessHost>
>
| undefined
try {
const market = new DshNpmMarketplaceCatalog()
const greet = (await market.list()).find(
(entry) => entry.package.name === 'dsh-plugin-greet'
)
expect(greet).toBeDefined()
expect(greet?.package).toEqual({
name: 'dsh-plugin-greet',
version: '0.2.0'
})
const npmCliPath = process.env.GOODBUDDY_DSH_NPM_CLI
? resolve(process.env.GOODBUDDY_DSH_NPM_CLI)
: resolve(
@@ -39,16 +49,17 @@ describe.skipIf(!enabled)('DSH marketplace live E2E', () => {
process.env.GOODBUDDY_DSH_NODE_EXECUTABLE
)
: undefined
const installer = new DshNpmExtensionInstaller({
const activeInstaller = new DshNpmExtensionInstaller({
dshHome: userDataPath,
npmCliPath,
...(nodeExecutablePath ? { nodeExecutablePath } : {})
})
installer = activeInstaller
const store = new RuntimeExtensionStore(userDataPath, {
catalog: {
list: async () => [greet!]
},
install: (input) => installer.install(input)
install: (input) => activeInstaller.install(input)
})
await store.apply({
type: 'set-marketplace-enabled',
@@ -77,7 +88,7 @@ describe.skipIf(!enabled)('DSH marketplace live E2E', () => {
Record<string, unknown>,
Record<string, unknown>
>()
const host = await startControlledDeepSeekHarnessHost({
host = await startControlledDeepSeekHarnessHost({
workspace: userDataPath,
dshHome: userDataPath,
baseUrl: 'https://api.deepseek.com',
@@ -103,10 +114,16 @@ describe.skipIf(!enabled)('DSH marketplace live E2E', () => {
} as never)
).resolves.toMatchObject({
isError: false,
value: 'Hello, GoodBuddy!'
value: {
message: 'Hello, GoodBuddy!',
name: 'GoodBuddy',
language: 'en',
style: 'friendly'
}
})
await host.dispose()
} finally {
await host?.dispose().catch(() => undefined)
await installer?.dispose().catch(() => undefined)
await rm(userDataPath, {
recursive: true,
force: true,
@@ -11,6 +11,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import {
DshNpmExtensionInstaller,
DshNpmMarketplaceCatalog,
runPackageManager,
type PackageManagerRunner
} from './dsh-extension-marketplace'
@@ -139,7 +140,7 @@ describe('DSH npm marketplace', () => {
expect(fetcher).toHaveBeenCalledOnce()
})
it('uses bundled npm to install the exact package and verifies its entrypoint', async () => {
it('installs the exact package when an older manifest lacks DSH bundle metadata', async () => {
const destinationDirectory = await mkdtemp(
join(tmpdir(), 'goodbuddy-dsh-npm-installer-')
)
@@ -161,6 +162,11 @@ describe('DSH npm marketplace', () => {
const fetcher = vi.fn<typeof fetch>(async () =>
response({
versions: {
'0.0.1': {
name: packageName,
version: '0.0.1',
dist: { integrity }
},
[version]: manifest
}
})
@@ -294,4 +300,95 @@ describe('DSH npm marketplace', () => {
})
).rejects.toThrow()
})
it('aborts an active package-manager process and settles the run', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-dsh-npm-abort-')
)
temporaryDirectories.push(directory)
const controller = new AbortController()
const operation = runPackageManager(
process.execPath,
['-e', 'setInterval(() => {}, 1_000)'],
{
cwd: directory,
env: process.env,
timeoutMs: 60_000,
signal: controller.signal
}
)
await new Promise((resolve) => setTimeout(resolve, 50))
controller.abort(new Error('installer cancellation fixture'))
await expect(operation).rejects.toThrow(
'installer cancellation fixture'
)
})
it('disposes active installs, propagates cancellation, and rejects new work', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-dsh-installer-dispose-')
)
temporaryDirectories.push(directory)
const integrity = `sha512-${Buffer.from('verified').toString(
'base64'
)}`
const packageName = 'dsh-plugin-cancellable'
const version = '1.0.0'
const runner: PackageManagerRunner = vi.fn(
(_command, _args, options) =>
new Promise<{
exitCode: number
stdout: string
stderr: string
}>((_resolve, reject) => {
const rejectCancellation = (): void => {
reject(options.signal?.reason)
}
options.signal?.addEventListener(
'abort',
rejectCancellation,
{ once: true }
)
})
)
const installer = new DshNpmExtensionInstaller({
dshHome: directory,
fetcher: vi.fn<typeof fetch>(async () =>
response({
versions: {
[version]: {
name: packageName,
version,
main: 'index.js',
dist: { integrity },
dsh: { bundle: { patch: './cordis.patch.yml' } }
}
}
})
),
runner
})
const input = {
entry: {
id: 'cancellable',
package: { name: packageName, version },
displayName: packageName,
description: 'Cancellation fixture.'
},
destinationDirectory: directory
}
const installation = installer.install(input)
await vi.waitFor(() => expect(runner).toHaveBeenCalledOnce())
await installer.dispose()
await expect(installation).rejects.toThrow(
'应用退出,DSH 插件安装已取消'
)
await expect(installer.install(input)).rejects.toThrow(
'DSH 插件安装器正在关闭'
)
})
})
+197 -36
View File
@@ -33,6 +33,7 @@ const MAXIMUM_CATALOG_ENTRIES = 1_000
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000
const DEFAULT_INSTALL_TIMEOUT_MS = 5 * 60_000
const MAXIMUM_PROCESS_OUTPUT_CHARACTERS = 64 * 1024
const MAXIMUM_PACKUMENT_VERSIONS = 20_000
const npmSearchPackageSchema = z
.object({
@@ -93,11 +94,59 @@ const npmVersionManifestSchema = npmInstalledManifestSchema.extend({
dist: npmDistributionSchema
})
const npmPackumentSchema = z
.object({
versions: z.record(z.string(), npmVersionManifestSchema)
function isPlainObject(
value: unknown
): value is Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false
}
const prototype = Object.getPrototypeOf(value)
return prototype === Object.prototype || prototype === null
}
const npmPackumentVersionsSchema = z
.custom<Record<string, unknown>>(isPlainObject, {
message: 'npm packument versions must be a plain object'
})
.passthrough()
.superRefine((versions, context) => {
const keys = Object.keys(versions)
if (keys.length > MAXIMUM_PACKUMENT_VERSIONS) {
context.addIssue({
code: 'too_big',
origin: 'object',
maximum: MAXIMUM_PACKUMENT_VERSIONS,
inclusive: true,
path: [],
message: 'npm packument contains too many versions'
})
}
if (
keys.some(
(key) =>
key === '__proto__' ||
key === 'prototype' ||
key === 'constructor'
)
) {
context.addIssue({
code: 'custom',
path: [],
message: 'npm packument contains an unsafe version key'
})
}
})
const npmPackumentSchema = z
.custom<Record<string, unknown>>(isPlainObject, {
message: 'npm packument must be a plain object'
})
.pipe(
z
.object({
versions: npmPackumentVersionsSchema
})
.passthrough()
)
type NpmVersionManifest = z.infer<typeof npmVersionManifestSchema>
@@ -114,6 +163,7 @@ export type PackageManagerRunner = (
cwd: string
env: NodeJS.ProcessEnv
timeoutMs: number
signal?: AbortSignal
}
) => Promise<PackageManagerRunResult>
@@ -124,11 +174,13 @@ function waitForProcessClose(
return Promise.resolve()
}
return new Promise((resolve) => {
const timer = setTimeout(resolve, 5_000)
child.once('close', () => {
const finish = (): void => {
clearTimeout(timer)
child.removeListener('close', finish)
resolve()
})
}
const timer = setTimeout(finish, 5_000)
child.once('close', finish)
})
}
@@ -147,11 +199,13 @@ async function terminatePackageManager(
}
)
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 5_000)
const finish = (): void => {
clearTimeout(timer)
killer.removeListener('close', finish)
killer.removeListener('error', finish)
resolve()
}
const timer = setTimeout(finish, 5_000)
killer.once('close', finish)
killer.once('error', finish)
})
@@ -183,6 +237,14 @@ export const runPackageManager: PackageManagerRunner = (
options
) =>
new Promise((resolve, reject) => {
if (options.signal?.aborted) {
reject(
options.signal.reason instanceof Error
? options.signal.reason
: new Error('DSH 插件安装已取消')
)
return
}
const child = spawn(command, [...args], {
cwd: options.cwd,
env: options.env,
@@ -194,42 +256,79 @@ export const runPackageManager: PackageManagerRunner = (
let stdout = ''
let stderr = ''
let settled = false
const timer = setTimeout(() => {
if (settled) {
return
}
settled = true
void terminatePackageManager(child).then(
() => reject(new Error('DSH 插件安装超时')),
() => reject(new Error('DSH 插件安装超时'))
)
}, options.timeoutMs)
child.stdout?.on('data', (chunk) => {
let terminating = false
const onStdout = (chunk: unknown): void => {
stdout = boundedAppend(stdout, chunk)
})
child.stderr?.on('data', (chunk) => {
}
const onStderr = (chunk: unknown): void => {
stderr = boundedAppend(stderr, chunk)
})
child.once('error', (error) => {
}
const cleanup = (): void => {
clearTimeout(timer)
options.signal?.removeEventListener('abort', onAbort)
child.stdout?.removeListener('data', onStdout)
child.stderr?.removeListener('data', onStderr)
child.removeListener('error', onError)
child.removeListener('close', onClose)
}
const settleRejected = (error: Error): void => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
cleanup()
reject(error)
})
child.once('close', (code) => {
if (settled) {
}
const terminateAndReject = (error: Error): void => {
if (settled || terminating) {
return
}
terminating = true
void terminatePackageManager(child).then(
() => settleRejected(error),
() => settleRejected(error)
)
}
const onAbort = (): void => {
terminateAndReject(
options.signal?.reason instanceof Error
? options.signal.reason
: new Error('DSH 插件安装已取消')
)
}
const onError = (error: Error): void => {
if (terminating) {
return
}
settleRejected(error)
}
const onClose = (code: number | null): void => {
if (settled || terminating) {
return
}
settled = true
clearTimeout(timer)
cleanup()
resolve({
exitCode: code ?? 1,
stdout,
stderr
})
}
const timer = setTimeout(
() =>
terminateAndReject(new Error('DSH 插件安装超时')),
options.timeoutMs
)
child.stdout?.on('data', onStdout)
child.stderr?.on('data', onStderr)
child.once('error', onError)
child.once('close', onClose)
options.signal?.addEventListener('abort', onAbort, {
once: true
})
if (options.signal?.aborted) {
onAbort()
}
})
function publicHttpUrl(value: string | undefined): string | undefined {
@@ -292,14 +391,18 @@ function catalogEntry(
async function fetchJson(
fetcher: typeof fetch,
url: URL,
timeoutMs: number
timeoutMs: number,
signal?: AbortSignal
): Promise<unknown> {
const timeoutSignal = AbortSignal.timeout(timeoutMs)
const response = await fetcher(url, {
headers: {
accept: 'application/json',
'user-agent': 'GoodBuddy-DSH-Marketplace/1'
},
signal: AbortSignal.timeout(timeoutMs)
signal: signal
? AbortSignal.any([signal, timeoutSignal])
: timeoutSignal
})
if (!response.ok) {
throw new Error(`DSH 插件市场请求失败(HTTP ${response.status}`)
@@ -520,6 +623,15 @@ async function prepareNodeCommand(
}
export class DshNpmExtensionInstaller {
private disposed = false
private readonly activeInstalls = new Map<
Promise<{
entrypoint: string
integrity?: string
}>,
AbortController
>()
constructor(
private readonly options: {
dshHome: string
@@ -534,7 +646,7 @@ export class DshNpmExtensionInstaller {
}
) {}
async install(
install(
input: Parameters<
RuntimeExtensionStoreDependencies['install']
>[0]
@@ -542,7 +654,45 @@ export class DshNpmExtensionInstaller {
entrypoint: string
integrity?: string
}> {
const manifest = await this.resolveManifest(input.entry)
if (this.disposed) {
return Promise.reject(new Error('DSH 插件安装器正在关闭'))
}
const controller = new AbortController()
const operation = this.performInstall(input, controller.signal)
this.activeInstalls.set(operation, controller)
void operation.then(
() => {
this.activeInstalls.delete(operation)
},
() => {
this.activeInstalls.delete(operation)
}
)
return operation
}
async dispose(): Promise<void> {
this.disposed = true
const active = [...this.activeInstalls.entries()]
for (const [, controller] of active) {
controller.abort(new Error('应用退出,DSH 插件安装已取消'))
}
await Promise.allSettled(
active.map(([operation]) => operation)
)
}
private async performInstall(
input: Parameters<
RuntimeExtensionStoreDependencies['install']
>[0],
signal: AbortSignal
): Promise<{
entrypoint: string
integrity?: string
}> {
const manifest = await this.resolveManifest(input.entry, signal)
signal.throwIfAborted()
await writeFile(
join(input.destinationDirectory, 'package.json'),
`${JSON.stringify(
@@ -608,6 +758,7 @@ export class DshNpmExtensionInstaller {
],
{
cwd: input.destinationDirectory,
signal,
env: {
...packageManagerEnvironment,
npm_config_audit: 'false',
@@ -625,6 +776,7 @@ export class DshNpmExtensionInstaller {
} catch (error) {
throw packageManagerError(error)
}
signal.throwIfAborted()
if (result.exitCode !== 0) {
const detail =
result.stderr.trim() ||
@@ -686,7 +838,8 @@ export class DshNpmExtensionInstaller {
}
private async resolveManifest(
entry: RuntimeExtensionCatalogEntry
entry: RuntimeExtensionCatalogEntry,
signal: AbortSignal
): Promise<NpmVersionManifest> {
const registryUrl = (
this.options.registryUrl ?? NPM_REGISTRY_URL
@@ -699,13 +852,21 @@ export class DshNpmExtensionInstaller {
this.options.fetcher ?? fetch,
url,
this.options.requestTimeoutMs ??
DEFAULT_REQUEST_TIMEOUT_MS
DEFAULT_REQUEST_TIMEOUT_MS,
signal
)
)
const manifest = packument.versions[entry.package.version]
if (!manifest) {
if (
!Object.prototype.hasOwnProperty.call(
packument.versions,
entry.package.version
)
) {
throw new Error('DSH 插件精确版本未发布')
}
const manifest = npmVersionManifestSchema.parse(
packument.versions[entry.package.version]
)
if (
manifest.name !== entry.package.name ||
manifest.version !== entry.package.version
+461 -2
View File
@@ -7,7 +7,14 @@ import { z } from 'zod'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { Server as McpProtocolServer } from '@modelcontextprotocol/sdk/server/index.js'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import {
LATEST_PROTOCOL_VERSION,
ListToolsRequestSchema,
isInitializeRequest,
type Tool
} from '@modelcontextprotocol/sdk/types.js'
import type { KnowledgeService } from '../knowledge/knowledge-service'
import { AssistantDatabase } from '../assistant/assistant-database'
import {
@@ -67,6 +74,115 @@ function createService() {
return { service, searchHybridMany }
}
function customMcpServer(
url: string,
id = '00000000-0000-4000-8000-000000000092'
) {
return {
id,
name: 'Paged MCP',
description: '',
enabled: true,
allowDynamicTools: true,
assignments: ['opencode' as const],
secretConfigured: false,
transport: 'http' as const,
url
}
}
async function startToolUpstream(
listTools: (
cursor: string | undefined
) =>
| { tools: Tool[]; nextCursor?: string }
| Promise<{ tools: Tool[]; nextCursor?: string }>
): Promise<{
url: string
notifyToolsChanged: () => Promise<void>
}> {
const sessions = new Map<
string,
{
protocol: McpProtocolServer
transport: StreamableHTTPServerTransport
}
>()
const server = createServer(async (request, response) => {
let body: unknown
if (request.method === 'POST') {
const chunks: Buffer[] = []
for await (const chunk of request) {
chunks.push(Buffer.from(chunk))
}
body = JSON.parse(Buffer.concat(chunks).toString('utf8'))
}
const sessionId = request.headers['mcp-session-id']
let session =
typeof sessionId === 'string'
? sessions.get(sessionId)
: undefined
if (!session) {
if (
request.method !== 'POST' ||
!isInitializeRequest(body)
) {
response.writeHead(404)
response.end()
return
}
const protocol = new McpProtocolServer(
{ name: 'tool-upstream', version: '1.0.0' },
{ capabilities: { tools: { listChanged: true } } }
)
protocol.setRequestHandler(
ListToolsRequestSchema,
async (requestValue) =>
listTools(requestValue.params?.cursor)
)
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
onsessioninitialized: (idValue) => {
sessions.set(idValue, { protocol, transport })
},
onsessionclosed: (idValue) => {
sessions.delete(idValue)
}
})
session = { protocol, transport }
await protocol.connect(transport)
}
await session.transport.handleRequest(request, response, body)
})
httpServers.push(server)
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
const address = server.address()
if (!address || typeof address === 'string') {
throw new Error('tool upstream did not bind')
}
return {
url: `http://127.0.0.1:${address.port}/mcp`,
notifyToolsChanged: async () => {
await Promise.all(
[...sessions.values()].map(({ protocol }) =>
protocol.sendToolListChanged()
)
)
}
}
}
function testTool(name: string): Tool {
return {
name,
description: name,
inputSchema: { type: 'object' }
}
}
const gateways: KnowledgeMcpGateway[] = []
const databases: AssistantDatabase[] = []
const temporaryDirectories: string[] = []
@@ -437,7 +553,7 @@ describe('KnowledgeMcpGateway', () => {
).toThrow('笔记不存在')
})
it('binds a POST-only authenticated endpoint and rejects oversized bodies', async () => {
it('binds an authenticated MCP endpoint and rejects oversized bodies', async () => {
const { service } = createService()
const gateway = new KnowledgeMcpGateway(service, {
maximumBodyBytes: 32
@@ -452,7 +568,7 @@ describe('KnowledgeMcpGateway', () => {
)!
const getResponse = await fetch(endpoint)
expect(getResponse.status).toBe(405)
expect(getResponse.status).toBe(401)
expect(getResponse.headers.get('access-control-allow-origin')).toBeNull()
const unauthorized = await fetch(endpoint, {
@@ -587,4 +703,347 @@ describe('KnowledgeMcpGateway', () => {
await client.close()
}
})
it('loads every tools/list page before exposing custom MCP tools', async () => {
const listTools = vi.fn((cursor: string | undefined) =>
cursor !== undefined
? { tools: [testTool('second')] }
: {
tools: [testTool('first')],
nextCursor: 'page-2'
}
)
const upstream = await startToolUpstream(listTools)
const { service } = createService()
const gateway = new KnowledgeMcpGateway(service)
gateways.push(gateway)
const token = gateway.grantCustomMcp(
'paged-tools',
[customMcpServer(upstream.url)],
new AbortController().signal
)!
const tools = await gateway.prepareCustomMcpTools(token)
expect(tools.map((tool) => tool.name)).toEqual([
expect.stringMatching(/_first$/u),
expect.stringMatching(/_second$/u)
])
expect(listTools).toHaveBeenNthCalledWith(1, undefined)
expect(listTools).toHaveBeenNthCalledWith(2, 'page-2')
})
it('continues tools/list pagination with an empty cursor', async () => {
const listTools = vi.fn((cursor: string | undefined) =>
cursor === undefined
? {
tools: [testTool('first')],
nextCursor: ''
}
: { tools: [testTool('second')] }
)
const upstream = await startToolUpstream(listTools)
const { service } = createService()
const gateway = new KnowledgeMcpGateway(service)
gateways.push(gateway)
const token = gateway.grantCustomMcp(
'empty-cursor-tools',
[customMcpServer(upstream.url)],
new AbortController().signal
)!
const tools = await gateway.prepareCustomMcpTools(token)
expect(tools.map((tool) => tool.name)).toEqual([
expect.stringMatching(/_first$/u),
expect.stringMatching(/_second$/u)
])
expect(listTools).toHaveBeenNthCalledWith(2, '')
})
it('explicitly requests task execution for required tools from earlier pages', async () => {
const { service } = createService()
const gateway = new KnowledgeMcpGateway(service)
gateways.push(gateway)
const server = customMcpServer('http://127.0.0.1:1/mcp')
const token = gateway.grantCustomMcp(
'required-task-tool',
[server],
new AbortController().signal
)!
const callToolStream = vi.fn(
async function* () {
yield {
type: 'result' as const,
result: {
content: [{ type: 'text' as const, text: 'done' }],
structuredContent: { value: 'done' }
}
}
}
)
const outputValidator = vi.fn(() => ({
valid: true as const,
data: { value: 'done' },
errorMessage: undefined
}))
const callRequiredTool = (
gateway as unknown as {
callCustomMcpTool(
capabilityToken: string,
binding: unknown,
input: Record<string, unknown>,
signal: AbortSignal
): Promise<unknown>
}
).callCustomMcpTool.bind(gateway)
await expect(
callRequiredTool(
token,
{
client: {
experimental: {
tasks: {
callToolStream,
cancelTask: vi.fn()
}
}
},
server,
originalName: 'required-first-page',
taskSupport: 'required',
outputValidator,
exposedTool: testTool('required-first-page')
},
{},
new AbortController().signal
)
).resolves.toMatchObject({
content: [{ type: 'text', text: 'done' }]
})
expect(callToolStream).toHaveBeenCalledWith(
{
name: 'required-first-page',
arguments: {}
},
undefined,
expect.objectContaining({ task: {} })
)
expect(outputValidator).toHaveBeenCalledWith({ value: 'done' })
})
it('rejects cyclic cursors and custom MCP tool counts over 100', async () => {
const cyclicUpstream = await startToolUpstream(
(cursor: string | undefined) => ({
tools: [testTool(cursor ? 'second' : 'first')],
nextCursor: 'cycle'
})
)
const excessiveUpstream = await startToolUpstream(() => ({
tools: Array.from({ length: 101 }, (_, index) =>
testTool(`tool-${index}`)
)
}))
const { service } = createService()
const gateway = new KnowledgeMcpGateway(service)
gateways.push(gateway)
const cycleToken = gateway.grantCustomMcp(
'cursor-cycle',
[customMcpServer(cyclicUpstream.url)],
new AbortController().signal
)!
const excessiveToken = gateway.grantCustomMcp(
'excessive-tools',
[
customMcpServer(
excessiveUpstream.url,
'00000000-0000-4000-8000-000000000093'
)
],
new AbortController().signal
)!
await expect(
gateway.prepareCustomMcpTools(cycleToken)
).rejects.toMatchObject({
cause: expect.objectContaining({
message: expect.stringContaining('分页游标发生循环')
})
})
await expect(
gateway.prepareCustomMcpTools(excessiveToken)
).rejects.toMatchObject({
cause: expect.objectContaining({
message: expect.stringContaining('工具数量超过安全限制')
})
})
})
it('releases rejected initialize attempts before enforcing the session limit', async () => {
const { service } = createService()
const gateway = new KnowledgeMcpGateway(service)
gateways.push(gateway)
await gateway.start()
const endpoint = gateway.getEndpoint()!
const token = gateway.grant(
'initialize-retry',
[firstLibraryId],
new AbortController().signal
)!
const initialize = {
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: {},
clientInfo: {
name: 'initialize-retry-fixture',
version: '1.0.0'
}
}
}
for (let attempt = 0; attempt < 8; attempt += 1) {
const rejected = await fetch(endpoint, {
method: 'POST',
headers: {
accept: 'application/json',
authorization: `Bearer ${token}`,
'content-type': 'application/json'
},
body: JSON.stringify(initialize)
})
expect(rejected.status).toBe(406)
}
const accepted = await fetch(endpoint, {
method: 'POST',
headers: {
accept: 'application/json, text/event-stream',
authorization: `Bearer ${token}`,
'content-type': 'application/json'
},
body: JSON.stringify(initialize)
})
expect(accepted.status).toBe(200)
expect(accepted.headers.get('mcp-session-id')).toEqual(
expect.any(String)
)
})
it('publishes upstream tool changes downstream after a successful refresh', async () => {
let tools = [testTool('before')]
const listTools = vi.fn(async () => ({ tools }))
const upstream = await startToolUpstream(listTools)
const { service } = createService()
const gateway = new KnowledgeMcpGateway(service)
gateways.push(gateway)
await gateway.start()
const token = gateway.grantCustomMcp(
'dynamic-tools',
[customMcpServer(upstream.url)],
new AbortController().signal
)!
const listChanged = vi.fn()
const client = new Client(
{ name: 'dynamic-client', version: '1.0.0' },
{
listChanged: {
tools: {
autoRefresh: false,
debounceMs: 0,
onChanged: listChanged
}
}
}
)
await client.connect(
new StreamableHTTPClientTransport(
new URL(gateway.getEndpoint()!),
{
requestInit: {
headers: { Authorization: `Bearer ${token}` }
}
}
)
)
try {
const initial = await client.listTools()
expect(initial.tools[0]?.name).toMatch(/_before$/u)
await new Promise((resolve) => setTimeout(resolve, 25))
tools = [testTool('after')]
await upstream.notifyToolsChanged()
await vi.waitFor(() => {
expect(listChanged).toHaveBeenCalledWith(null, null)
})
const updated = await client.listTools()
expect(updated.tools.map((tool) => tool.name)).toEqual([
expect.stringMatching(/_after$/u)
])
} finally {
await client.close()
}
})
it('does not publish a downstream change when dynamic refresh fails', async () => {
let failRefresh = false
const listTools = vi.fn(async () => {
if (failRefresh) {
throw new Error('refresh failed')
}
return { tools: [testTool('stable')] }
})
const upstream = await startToolUpstream(listTools)
const { service } = createService()
const gateway = new KnowledgeMcpGateway(service)
gateways.push(gateway)
await gateway.start()
const token = gateway.grantCustomMcp(
'failed-refresh',
[customMcpServer(upstream.url)],
new AbortController().signal
)!
const listChanged = vi.fn()
const client = new Client(
{ name: 'failed-refresh-client', version: '1.0.0' },
{
listChanged: {
tools: {
autoRefresh: false,
debounceMs: 0,
onChanged: listChanged
}
}
}
)
await client.connect(
new StreamableHTTPClientTransport(
new URL(gateway.getEndpoint()!),
{
requestInit: {
headers: { Authorization: `Bearer ${token}` }
}
}
)
)
try {
await client.listTools()
await new Promise((resolve) => setTimeout(resolve, 25))
failRefresh = true
await upstream.notifyToolsChanged()
await vi.waitFor(() => {
expect(listTools).toHaveBeenCalledTimes(2)
})
await new Promise((resolve) => setTimeout(resolve, 25))
expect(listChanged).not.toHaveBeenCalled()
} finally {
await client.close()
}
})
})
+376 -106
View File
@@ -8,10 +8,13 @@ import {
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { Server as McpProtocolServer } from '@modelcontextprotocol/sdk/server/index.js'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import type { JsonSchemaValidator } from '@modelcontextprotocol/sdk/validation'
import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'
import {
CallToolRequestSchema,
CallToolResultSchema,
ListToolsRequestSchema,
isInitializeRequest,
type CallToolResult,
type Tool
} from '@modelcontextprotocol/sdk/types.js'
@@ -57,6 +60,7 @@ import type {
import {
createMcpToolName,
isValidMcpToolName,
listAllMcpTools,
normalizeMcpToolSchema
} from './mcp-tool-utils'
@@ -65,11 +69,13 @@ const MAX_RESULT_BYTES = 128 * 1024
const MAX_CUSTOM_MCP_RESULT_BYTES = 256 * 1024
const MAX_CUSTOM_MCP_SERVERS = 16
const MAX_CUSTOM_MCP_TOOLS = 100
const MAX_DOWNSTREAM_MCP_SESSIONS_PER_CAPABILITY = 8
const CUSTOM_MCP_TIMEOUT_MS = 30_000
const CUSTOM_MCP_MAX_TOTAL_TIMEOUT_MS = 5 * 60_000
const CUSTOM_MCP_TASK_CANCEL_TIMEOUT_MS = 5_000
const DEFAULT_CAPABILITY_TTL_MS = 10 * 60_000
const MAX_CAPABILITY_TTL_MS = 15 * 60_000
const customMcpJsonSchemaValidator = new AjvJsonSchemaValidator()
export {
knowledgeToolNames,
@@ -179,6 +185,7 @@ type CustomMcpBinding = {
originalName: string
exposedTool: Tool
taskSupport?: 'forbidden' | 'optional' | 'required'
outputValidator?: JsonSchemaValidator<Record<string, unknown>>
}
type CustomMcpConnection = {
@@ -187,6 +194,19 @@ type CustomMcpConnection = {
bindings: CustomMcpBinding[]
dynamicToolsSupported: boolean
dynamicToolsChanged: boolean
dynamicToolsChangeVersion: number
dynamicToolsRefresh?: Promise<void>
}
type DownstreamMcpSession = {
id?: string
registryKey: string
token: string
mcp: McpProtocolServer
transport: StreamableHTTPServerTransport
initialized: boolean
listedTools: boolean
closing?: Promise<void>
}
export type KnowledgeMcpGatewayOptions = {
@@ -308,6 +328,11 @@ async function readBoundedJson(
export class KnowledgeMcpGateway {
private readonly capabilities = new Map<string, Capability>()
private readonly downstreamMcpSessions = new Map<
string,
DownstreamMcpSession
>()
private readonly downstreamMcpCleanups = new Set<Promise<void>>()
private readonly customMcpCleanups = new Set<Promise<void>>()
private readonly now: () => number
private readonly capabilityTtlMs: number
@@ -490,6 +515,11 @@ export class KnowledgeMcpGateway {
capability.brokerController.abort(
new Error('MCP capability was revoked')
)
for (const session of this.downstreamMcpSessions.values()) {
if (session.token === token) {
void this.closeDownstreamMcpSession(session)
}
}
if (capability.configAccess !== 'none') {
this.configService?.revokeRequest(capability.requestId)
}
@@ -500,6 +530,24 @@ export class KnowledgeMcpGateway {
})
}
private closeDownstreamMcpSession(
session: DownstreamMcpSession
): Promise<void> {
if (session.closing) {
return session.closing
}
this.downstreamMcpSessions.delete(session.registryKey)
const cleanup = session.mcp
.close()
.catch(() => undefined)
.finally(() => {
this.downstreamMcpCleanups.delete(cleanup)
})
session.closing = cleanup
this.downstreamMcpCleanups.add(cleanup)
return cleanup
}
drainReferences(
token: string | undefined
): KnowledgeSearchReference[] {
@@ -673,6 +721,11 @@ export class KnowledgeMcpGateway {
server,
originalName: tool.name,
taskSupport: tool.execution?.taskSupport,
outputValidator: tool.outputSchema
? customMcpJsonSchemaValidator.getValidator<
Record<string, unknown>
>(normalizeMcpToolSchema(tool.outputSchema))
: undefined,
exposedTool: {
name: createMcpToolName(server.id, tool.name),
title: `${server.name} / ${tool.name}`.slice(0, 200),
@@ -690,11 +743,116 @@ export class KnowledgeMcpGateway {
})
}
private async listAllCustomMcpTools(
client: Client,
server: ResolvedMcpServer,
signal: AbortSignal
): Promise<Awaited<ReturnType<Client['listTools']>>['tools']> {
return listAllMcpTools(client, server.name, signal, {
maximumTools: MAX_CUSTOM_MCP_TOOLS,
pageTimeoutMs: CUSTOM_MCP_TIMEOUT_MS,
totalTimeoutMs: CUSTOM_MCP_MAX_TOTAL_TIMEOUT_MS
})
}
private async publishCustomMcpToolListChanged(
capability: Capability
): Promise<void> {
const token = [...this.capabilities.entries()].find(
([, value]) => value === capability
)?.[0]
if (!token) {
return
}
const sessions = [...this.downstreamMcpSessions.values()].filter(
(session) =>
session.token === token &&
session.initialized &&
session.listedTools
)
await Promise.allSettled(
sessions.map((session) => session.mcp.sendToolListChanged())
)
}
private scheduleDynamicToolsRefresh(
capability: Capability,
connection: CustomMcpConnection
): void {
void this.refreshDynamicTools(capability, connection).catch(
() => undefined
)
}
private refreshDynamicTools(
capability: Capability,
connection: CustomMcpConnection,
signal?: AbortSignal
): Promise<void> {
if (connection.dynamicToolsRefresh) {
return connection.dynamicToolsRefresh
}
const effectiveSignal = signal
? AbortSignal.any([
signal,
capability.signal,
capability.brokerController.signal
])
: AbortSignal.any([
capability.signal,
capability.brokerController.signal
])
const changeVersion = connection.dynamicToolsChangeVersion
let refreshSucceeded = false
const refresh = (async () => {
try {
const tools = await this.listAllCustomMcpTools(
connection.client,
connection.server,
effectiveSignal
)
const bindings = this.createCustomMcpBindings(
connection.client,
connection.server,
tools
)
connection.bindings = bindings
connection.dynamicToolsChanged =
connection.dynamicToolsChangeVersion !== changeVersion
refreshSucceeded = true
await this.publishCustomMcpToolListChanged(capability)
} catch (error) {
connection.dynamicToolsChanged = true
if (effectiveSignal.aborted) {
throw effectiveSignal.reason
}
throw new Error(
`无法刷新 MCP Server「${connection.server.name}」的工具`,
{ cause: error }
)
}
})()
connection.dynamicToolsRefresh = refresh
void refresh.finally(() => {
connection.dynamicToolsRefresh = undefined
if (
refreshSucceeded &&
connection.dynamicToolsChanged &&
!capability.signal.aborted &&
!capability.brokerController.signal.aborted
) {
this.scheduleDynamicToolsRefresh(capability, connection)
}
}).catch(() => undefined)
return refresh
}
private async connectCustomMcpServer(
capability: Capability,
server: ResolvedMcpServer
): Promise<CustomMcpConnection> {
let connection: CustomMcpConnection | undefined
let dynamicToolsChangeVersion = 0
const client = new Client(
{
name: 'goodbuddy-main-mcp-broker',
@@ -707,8 +865,17 @@ export class KnowledgeMcpGateway {
autoRefresh: false,
debounceMs: 0,
onChanged: (error) => {
if (!error && connection) {
connection.dynamicToolsChanged = true
if (!error) {
dynamicToolsChangeVersion += 1
if (connection) {
connection.dynamicToolsChangeVersion =
dynamicToolsChangeVersion
connection.dynamicToolsChanged = true
this.scheduleDynamicToolsRefresh(
capability,
connection
)
}
}
}
}
@@ -725,22 +892,29 @@ export class KnowledgeMcpGateway {
timeout: CUSTOM_MCP_TIMEOUT_MS,
signal
})
const result = await client.listTools(undefined, {
timeout: CUSTOM_MCP_TIMEOUT_MS,
const listedAtChangeVersion = dynamicToolsChangeVersion
const tools = await this.listAllCustomMcpTools(
client,
server,
signal
})
)
connection = {
client,
server,
bindings: this.createCustomMcpBindings(
client,
server,
result.tools
tools
),
dynamicToolsSupported:
server.allowDynamicTools &&
client.getServerCapabilities()?.tools?.listChanged === true,
dynamicToolsChanged: false
dynamicToolsChanged:
dynamicToolsChangeVersion !== listedAtChangeVersion,
dynamicToolsChangeVersion
}
if (connection.dynamicToolsChanged) {
this.scheduleDynamicToolsRefresh(capability, connection)
}
return connection
} catch (error) {
@@ -791,41 +965,19 @@ export class KnowledgeMcpGateway {
throw error
}
if (refreshDynamic) {
const effectiveSignal = signal
? AbortSignal.any([
signal,
capability.signal,
capability.brokerController.signal
])
: AbortSignal.any([
capability.signal,
capability.brokerController.signal
])
for (const connection of connections) {
if (
!connection.dynamicToolsSupported ||
!connection.dynamicToolsChanged
(!connection.dynamicToolsChanged &&
!connection.dynamicToolsRefresh)
) {
continue
}
connection.dynamicToolsChanged = false
try {
const result = await connection.client.listTools(undefined, {
timeout: CUSTOM_MCP_TIMEOUT_MS,
signal: effectiveSignal
})
connection.bindings = this.createCustomMcpBindings(
connection.client,
connection.server,
result.tools
)
} catch (error) {
connection.dynamicToolsChanged = true
throw new Error(
`无法刷新 MCP Server「${connection.server.name}」的工具`,
{ cause: error }
)
}
await this.refreshDynamicTools(
capability,
connection,
signal
)
}
}
const bindings = new Map<string, CustomMcpBinding>()
@@ -877,7 +1029,8 @@ export class KnowledgeMcpGateway {
}
try {
if (binding.taskSupport !== 'required') {
return ensureBoundedCustomMcpResult(
return this.validateCustomMcpResult(
binding,
await binding.client.callTool(params, undefined, options)
)
}
@@ -886,7 +1039,10 @@ export class KnowledgeMcpGateway {
for await (const message of binding.client.experimental.tasks.callToolStream(
params,
undefined,
options
{
...options,
task: {}
}
)) {
if (
(message.type === 'taskCreated' ||
@@ -895,7 +1051,10 @@ export class KnowledgeMcpGateway {
) {
taskId = message.task.taskId
} else if (message.type === 'result') {
return ensureBoundedCustomMcpResult(message.result)
return this.validateCustomMcpResult(
binding,
message.result
)
} else if (message.type === 'error') {
throw message.error
}
@@ -923,6 +1082,33 @@ export class KnowledgeMcpGateway {
}
}
private validateCustomMcpResult(
binding: CustomMcpBinding,
result: unknown
): CallToolResult {
const bounded = ensureBoundedCustomMcpResult(result)
if (!binding.outputValidator) {
return bounded
}
if (!bounded.structuredContent) {
if (!bounded.isError) {
throw new Error(
`MCP 工具「${binding.originalName}」未返回结构化结果`
)
}
return bounded
}
const validation = binding.outputValidator(
bounded.structuredContent
)
if (!validation.valid) {
throw new Error(
`MCP 工具「${binding.originalName}」返回结果不符合声明结构:${validation.errorMessage.slice(0, 500)}`
)
}
return bounded
}
private async closeCustomMcpConnections(
capability: Capability
): Promise<void> {
@@ -1218,55 +1404,10 @@ export class KnowledgeMcpGateway {
}
}
private async handleRequest(
request: IncomingMessage,
response: ServerResponse
): Promise<void> {
if (request.url !== '/mcp') {
sendJson(response, 404, { error: 'Not found' })
return
}
if (request.method !== 'POST') {
response.setHeader('allow', 'POST')
sendJson(response, 405, {
jsonrpc: '2.0',
error: { code: -32000, message: 'Method not allowed' },
id: null
})
return
}
const authorization = request.headers.authorization
if (
typeof authorization !== 'string' ||
!authorization.startsWith('Bearer ')
) {
sendJson(response, 401, { error: 'Unauthorized' })
return
}
const token = authorization.slice('Bearer '.length)
try {
this.getCapability(token)
} catch {
sendJson(response, 401, { error: 'Unauthorized' })
return
}
let body: unknown
try {
body = await readBoundedJson(request, this.maximumBodyBytes)
} catch (error) {
sendJson(response, error instanceof RangeError ? 413 : 400, {
error:
error instanceof RangeError
? 'Request body too large'
: 'Invalid JSON'
})
return
}
const availableScopedTools = new Set(
this.getAvailableToolNames(token)
)
private createDownstreamMcpSession(
token: string,
availableScopedTools: ReadonlySet<ScopedDataToolName>
): DownstreamMcpSession {
const mcp = new McpProtocolServer(
{
name: 'goodbuddy-request-scoped-capabilities',
@@ -1274,10 +1415,38 @@ export class KnowledgeMcpGateway {
},
{
capabilities: {
tools: {}
tools: {
listChanged: true
}
}
}
)
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomBytes(32).toString('base64url'),
onsessioninitialized: (sessionId) => {
this.downstreamMcpSessions.delete(session.registryKey)
session.id = sessionId
session.registryKey = sessionId
this.downstreamMcpSessions.set(sessionId, session)
},
onsessionclosed: (sessionId) => {
this.downstreamMcpSessions.delete(sessionId)
}
})
const session: DownstreamMcpSession = {
registryKey: randomBytes(32).toString('base64url'),
token,
mcp,
transport,
initialized: false,
listedTools: false
}
transport.onclose = () => {
this.downstreamMcpSessions.delete(session.registryKey)
}
mcp.oninitialized = () => {
session.initialized = true
}
mcp.setRequestHandler(
ListToolsRequestSchema,
async (_request, extra) => {
@@ -1287,9 +1456,7 @@ export class KnowledgeMcpGateway {
)
const scopedTools = [...availableScopedTools].flatMap(
(name): Tool[] => {
const definition = scopedDataToolByName.get(
name as ScopedDataToolName
)
const definition = scopedDataToolByName.get(name)
if (!definition) {
return []
}
@@ -1315,6 +1482,7 @@ export class KnowledgeMcpGateway {
]
}
)
session.listedTools = true
return {
tools: [
...scopedTools,
@@ -1330,9 +1498,7 @@ export class KnowledgeMcpGateway {
async (call, extra) => {
const name = call.params.name
const input = call.params.arguments ?? {}
if (
availableScopedTools.has(name as ScopedDataToolName)
) {
if (availableScopedTools.has(name as ScopedDataToolName)) {
const definition = scopedDataToolByName.get(
name as ScopedDataToolName
)
@@ -1369,20 +1535,123 @@ export class KnowledgeMcpGateway {
)
}
)
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined
})
const close = (): void => {
void Promise.allSettled([transport.close(), mcp.close()])
return session
}
private async handleRequest(
request: IncomingMessage,
response: ServerResponse
): Promise<void> {
if (request.url !== '/mcp') {
sendJson(response, 404, { error: 'Not found' })
return
}
response.once('close', close)
if (
request.method !== 'POST' &&
request.method !== 'GET' &&
request.method !== 'DELETE'
) {
response.setHeader('allow', 'POST, GET, DELETE')
sendJson(response, 405, {
jsonrpc: '2.0',
error: { code: -32000, message: 'Method not allowed' },
id: null
})
return
}
const authorization = request.headers.authorization
if (
typeof authorization !== 'string' ||
!authorization.startsWith('Bearer ')
) {
sendJson(response, 401, { error: 'Unauthorized' })
return
}
const token = authorization.slice('Bearer '.length)
try {
await mcp.connect(transport)
await transport.handleRequest(request, response, body)
this.getCapability(token)
} catch {
sendJson(response, 401, { error: 'Unauthorized' })
return
}
let body: unknown
if (request.method === 'POST') {
try {
body = await readBoundedJson(request, this.maximumBodyBytes)
} catch (error) {
sendJson(response, error instanceof RangeError ? 413 : 400, {
error:
error instanceof RangeError
? 'Request body too large'
: 'Invalid JSON'
})
return
}
}
const sessionId = request.headers['mcp-session-id']
let createdSession = false
let session =
typeof sessionId === 'string'
? this.downstreamMcpSessions.get(sessionId)
: undefined
if (session && session.token !== token) {
session = undefined
}
if (!session) {
if (
request.method !== 'POST' ||
!isInitializeRequest(body) ||
typeof sessionId === 'string'
) {
sendJson(response, typeof sessionId === 'string' ? 404 : 400, {
jsonrpc: '2.0',
error: {
code:
typeof sessionId === 'string' ? -32001 : -32000,
message:
typeof sessionId === 'string'
? 'Session not found'
: 'Bad Request: No valid session ID provided'
},
id: null
})
return
}
const sessionCount = [
...this.downstreamMcpSessions.values()
].filter((candidate) => candidate.token === token).length
if (
sessionCount >=
MAX_DOWNSTREAM_MCP_SESSIONS_PER_CAPABILITY
) {
sendJson(response, 429, {
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Too many MCP sessions'
},
id: null
})
return
}
const availableScopedTools = new Set(
this.getAvailableToolNames(token)
)
session = this.createDownstreamMcpSession(
token,
availableScopedTools
)
createdSession = true
this.downstreamMcpSessions.set(session.registryKey, session)
await session.mcp.connect(session.transport)
}
try {
await session.transport.handleRequest(request, response, body)
} finally {
if (response.writableFinished) {
response.off('close', close)
close()
if (createdSession && session.id === undefined) {
await this.closeDownstreamMcpSession(session)
}
}
}
@@ -1391,6 +1660,7 @@ export class KnowledgeMcpGateway {
for (const token of [...this.capabilities.keys()]) {
this.revoke(token)
}
await Promise.allSettled([...this.downstreamMcpCleanups])
await Promise.allSettled([...this.customMcpCleanups])
const server = this.server
this.server = undefined
+71
View File
@@ -1,6 +1,77 @@
import { createHash } from 'node:crypto'
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
const MAXIMUM_MCP_TOOL_SCHEMA_BYTES = 32 * 1024
const DEFAULT_MAXIMUM_MCP_TOOL_PAGES = 100
type ListedMcpTool =
Awaited<ReturnType<Client['listTools']>>['tools'][number]
export async function listAllMcpTools(
client: Pick<Client, 'listTools'>,
serverName: string,
signal: AbortSignal,
options: {
maximumTools: number
pageTimeoutMs: number
totalTimeoutMs: number
maximumPages?: number
}
): Promise<ListedMcpTool[]> {
const tools: ListedMcpTool[] = []
const toolNames = new Set<string>()
const cursors = new Set<string>()
const deadline = Date.now() + options.totalTimeoutMs
const maximumPages =
options.maximumPages ?? DEFAULT_MAXIMUM_MCP_TOOL_PAGES
let cursor: string | undefined
for (let page = 0; page < maximumPages; page += 1) {
signal.throwIfAborted()
const remainingMs = deadline - Date.now()
if (remainingMs <= 0) {
throw new Error(
`MCP Server「${serverName}」的工具分页超过总超时`
)
}
const result = await client.listTools(
cursor !== undefined ? { cursor } : undefined,
{
timeout: Math.max(
1,
Math.min(options.pageTimeoutMs, remainingMs)
),
signal
}
)
for (const tool of result.tools) {
if (toolNames.has(tool.name)) {
throw new Error(
`MCP Server「${serverName}」返回了重复工具「${tool.name}`
)
}
toolNames.add(tool.name)
tools.push(tool)
if (tools.length > options.maximumTools) {
throw new Error(
`MCP Server「${serverName}」提供的工具数量超过安全限制`
)
}
}
if (result.nextCursor === undefined) {
return tools
}
if (cursors.has(result.nextCursor)) {
throw new Error(
`MCP Server「${serverName}」的工具分页游标发生循环`
)
}
cursors.add(result.nextCursor)
cursor = result.nextCursor
}
throw new Error(
`MCP Server「${serverName}」的工具分页超过安全限制`
)
}
export function isValidMcpToolName(value: unknown): value is string {
return (
+128
View File
@@ -490,6 +490,134 @@ describe('ModelToolProvider', () => {
await overflowingProvider.dispose()
})
it('loads every paginated MCP tool before exposing the catalog', async () => {
const workspace = await createWorkspace()
mocks.client.listTools.mockImplementation(
async (params?: { cursor?: string }) =>
params?.cursor === ''
? {
tools: [
{
name: 'second',
inputSchema: {
type: 'object',
properties: {}
}
}
]
}
: {
tools: [
{
name: 'first',
inputSchema: {
type: 'object',
properties: {}
}
}
],
nextCursor: ''
}
)
const provider = new ModelToolProvider(workspace, [
createMcpServer()
])
const tools = await provider.listTools(
toolContext,
new AbortController().signal
)
expect(tools).toEqual(
expect.arrayContaining([
expect.objectContaining({ displayName: 'Search MCP / first' }),
expect.objectContaining({ displayName: 'Search MCP / second' })
])
)
expect(mocks.client.listTools).toHaveBeenNthCalledWith(
2,
{ cursor: '' },
expect.objectContaining({ timeout: expect.any(Number) })
)
await provider.dispose()
})
it('refreshes a dynamic MCP change announced during initial listing', async () => {
const workspace = await createWorkspace()
let resolveInitialList:
| ((value: {
tools: Array<{
name: string
inputSchema: {
type: 'object'
properties: Record<string, never>
}
}>
}) => void)
| undefined
mocks.client.getServerCapabilities.mockReturnValue({
tools: { listChanged: true }
})
mocks.client.listTools
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveInitialList = resolve
})
)
.mockResolvedValueOnce({
tools: [
{
name: 'current',
inputSchema: {
type: 'object',
properties: {}
}
}
]
})
const provider = new ModelToolProvider(workspace, [
createMcpServer(true)
])
const listing = provider.listTools(
toolContext,
new AbortController().signal
)
await vi.waitFor(() => {
expect(mocks.client.listTools).toHaveBeenCalledOnce()
})
const options = mocks.Client.mock.calls[0]?.[1] as
| {
listChanged?: {
tools?: {
onChanged?: (error?: Error) => void
}
}
}
| undefined
options?.listChanged?.tools?.onChanged?.()
resolveInitialList?.({
tools: [
{
name: 'stale',
inputSchema: {
type: 'object',
properties: {}
}
}
]
})
await expect(listing).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ displayName: 'Search MCP / current' })
])
)
expect(mocks.client.listTools).toHaveBeenCalledTimes(2)
await provider.dispose()
})
it('rejects workspace traversal before accessing the filesystem', async () => {
const workspace = await createWorkspace()
const provider = new ModelToolProvider(workspace)
+34 -13
View File
@@ -41,6 +41,7 @@ import type { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
import {
createMcpToolName,
isValidMcpToolName,
listAllMcpTools,
normalizeMcpToolSchema
} from './mcp-tool-utils'
@@ -736,6 +737,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
clientScope: Set<Client> = this.customMcpClients
): Promise<ConnectedMcp> {
let connection: ConnectedMcp | undefined
let dynamicToolsChangeVersion = 0
const client = new Client(
{
name: 'goodbuddy-direct-model',
@@ -748,8 +750,11 @@ export class ModelToolProvider implements ModelToolProviderLike {
autoRefresh: false,
debounceMs: 0,
onChanged: (error) => {
if (!error && connection) {
connection.dynamicToolsChanged = true
if (!error) {
dynamicToolsChangeVersion += 1
if (connection) {
connection.dynamicToolsChanged = true
}
}
}
}
@@ -764,18 +769,27 @@ export class ModelToolProvider implements ModelToolProviderLike {
timeout: MCP_TIMEOUT_MS,
signal
})
const result = await client.listTools(undefined, {
timeout: MCP_TIMEOUT_MS,
signal
})
const listedAtChangeVersion = dynamicToolsChangeVersion
const tools = await listAllMcpTools(
client,
server.name,
signal,
{
maximumTools:
MAX_MODEL_TOOLS - this.getReservedToolCount(),
pageTimeoutMs: MCP_TIMEOUT_MS,
totalTimeoutMs: MCP_CALL_MAX_TOTAL_TIMEOUT_MS
}
)
connection = {
client,
server,
tools: this.createMcpBindings(client, server, result.tools),
tools: this.createMcpBindings(client, server, tools),
dynamicToolsSupported:
server.allowDynamicTools &&
client.getServerCapabilities()?.tools?.listChanged === true,
dynamicToolsChanged: false
dynamicToolsChanged:
dynamicToolsChangeVersion !== listedAtChangeVersion
}
return connection
} catch (error) {
@@ -863,14 +877,21 @@ export class ModelToolProvider implements ModelToolProviderLike {
}
connection.dynamicToolsChanged = false
try {
const result = await connection.client.listTools(undefined, {
timeout: MCP_TIMEOUT_MS,
signal
})
const tools = await listAllMcpTools(
connection.client,
connection.server.name,
signal,
{
maximumTools:
MAX_MODEL_TOOLS - this.getReservedToolCount(),
pageTimeoutMs: MCP_TIMEOUT_MS,
totalTimeoutMs: MCP_CALL_MAX_TOTAL_TIMEOUT_MS
}
)
connection.tools = this.createMcpBindings(
connection.client,
connection.server,
result.tools
tools
)
} catch (error) {
connection.dynamicToolsChanged = true
+129 -9
View File
@@ -2984,7 +2984,7 @@ describe('OpenCodeRuntime native customization', () => {
await runtime.dispose()
})
it('compacts an existing managed session through the v2 API', async () => {
it('compacts a managed session through the supported native API', async () => {
const setup = runClient([
{
id: 'idle',
@@ -2993,19 +2993,74 @@ describe('OpenCodeRuntime native customization', () => {
}
])
const context = vi.fn().mockResolvedValue({
data: { data: [] }
data: {
data: [
{
type: 'assistant',
model: {
providerID: 'anthropic',
id: 'claude-sonnet'
}
}
]
}
})
const compact = vi.fn().mockResolvedValue({
data: undefined,
const summarize = vi.fn().mockResolvedValue({
data: true,
error: undefined
})
Object.assign(setup.client, {
v2: {
session: { context, compact }
}
session: { context }
},
session: { ...setup.client.session, summarize }
})
const runtime = embeddedRuntime(setup.client)
await collectRun(runtime)
vi.mocked(setup.event.subscribe).mockResolvedValueOnce({
stream: (async function* () {
yield {
type: 'message.updated',
properties: {
sessionID: 'session-1',
info: {
id: 'compaction-message',
sessionID: 'session-1',
role: 'assistant',
time: {
created: 1,
completed: 2
},
parentID: 'compaction-parent',
modelID: 'claude-sonnet',
providerID: 'anthropic',
mode: 'compaction',
agent: 'build',
path: {
cwd: process.cwd(),
root: process.cwd()
},
cost: 0,
tokens: {
input: 100,
output: 20,
reasoning: 0,
cache: {
read: 30,
write: 4
},
total: 124
},
finish: 'stop'
}
}
}
yield {
type: 'session.idle',
properties: { sessionID: 'session-1' }
}
})()
} as never)
const signal = new AbortController().signal
await expect(
@@ -3025,19 +3080,84 @@ describe('OpenCodeRuntime native customization', () => {
strategy: 'native',
compacted: true,
detail: 'OpenCode 已完成原生上下文压缩'
}
},
usageEvents: [
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
type: 'model-usage',
callId: 'compaction-message',
runtime: 'opencode',
provider: 'anthropic',
model: 'claude-sonnet',
inputTokens: 100,
outputTokens: 20,
cacheReadTokens: 30,
cacheWriteTokens: 4,
reportedTotalTokens: 124
}
]
})
expect(context).toHaveBeenCalledWith(
{ sessionID: 'session-1' },
{ signal }
)
expect(compact).toHaveBeenCalledWith(
{ sessionID: 'session-1' },
expect(summarize).toHaveBeenCalledWith(
{
sessionID: 'session-1',
directory: process.cwd(),
providerID: 'anthropic',
modelID: 'claude-sonnet',
auto: false
},
{ signal }
)
await runtime.dispose()
})
it('reports when a managed session has no model to compact with', async () => {
const setup = runClient([
{
id: 'idle',
type: 'session.idle',
properties: { sessionID: 'session-1' }
}
])
const context = vi.fn().mockResolvedValue({
data: { data: [] }
})
const summarize = vi.fn()
Object.assign(setup.client, {
v2: {
session: { context }
},
session: { ...setup.client.session, summarize }
})
const runtime = embeddedRuntime(setup.client)
await collectRun(runtime)
await expect(
runtime.compactConversation(
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-1',
runtimeSelection: { provider: 'opencode' },
history: [],
historyMessageIds: []
},
new AbortController().signal
)
).resolves.toEqual({
result: {
provider: 'opencode',
strategy: 'native',
compacted: false,
detail: '当前 OpenCode 会话尚无可用于压缩的模型记录'
}
})
expect(summarize).not.toHaveBeenCalled()
await runtime.dispose()
})
it('reports when no managed OpenCode session can be compacted', async () => {
const runtime = new OpenCodeRuntime(options())
+113 -19
View File
@@ -77,6 +77,7 @@ const MAX_NATIVE_MCP_SERVERS =
runtimeNativeInventoryLimits.mcpServers
const MAX_NATIVE_SKILLS = runtimeNativeInventoryLimits.skills
const MAX_NATIVE_RESOURCES = runtimeNativeInventoryLimits.resources
const COMPACTION_USAGE_EVENT_GRACE_MS = 1_000
const EMBEDDED_SERVER_USERNAME = 'goodbuddy'
const OPENCODE_SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u
const TEMPORARY_MCP_PREFIXES = [
@@ -2407,27 +2408,120 @@ export class OpenCodeRuntime implements AgentRuntime {
if (context.error || !context.data) {
throw new Error('OpenCode 原生上下文不可用,无法执行 Compact')
}
signal.throwIfAborted()
const compact = await client.v2.session.compact(
{ sessionID: sessionId },
{ signal }
)
if (compact.error) {
throw new Error(
opencodeErrorMessage(
compact.error,
'OpenCode 原生 Compact 失败'
)
)
}
return {
result: {
provider: 'opencode',
strategy: 'native',
compacted: true,
detail: 'OpenCode 已完成原生上下文压缩'
const latestAssistant = [...context.data.data]
.reverse()
.find((message) => message.type === 'assistant')
const configuredModel = this.options.modelProfile
? {
providerID: resolveOpenCodeProvider(
this.options.modelProfile
).id,
modelID: this.options.modelProfile.modelName
}
: latestAssistant?.type === 'assistant'
? {
providerID: latestAssistant.model.providerID,
modelID: latestAssistant.model.id
}
: undefined
if (!configuredModel) {
return {
result: {
provider: 'opencode',
strategy: 'native',
compacted: false,
detail: '当前 OpenCode 会话尚无可用于压缩的模型记录'
}
}
}
signal.throwIfAborted()
const subscriptionController = new AbortController()
const subscription = await client.event.subscribe(
{ directory: this.options.defaultWorkspace },
{
signal: AbortSignal.any([
signal,
subscriptionController.signal
])
}
)
const usageEvents: RuntimeModelUsageEvent[] = []
const reportedMessageIds = new Set<string>()
const usageCapture = (async () => {
for await (const event of subscription.stream) {
if (
event.type === 'message.updated' &&
event.properties.sessionID === sessionId &&
event.properties.info.sessionID === sessionId &&
event.properties.info.role === 'assistant' &&
!reportedMessageIds.has(event.properties.info.id)
) {
const usage = createUsageEvent(
request.requestId,
event.properties.info
)
if (usage) {
reportedMessageIds.add(event.properties.info.id)
usageEvents.push(usage)
}
}
if (
event.type === 'session.idle' &&
event.properties.sessionID === sessionId
) {
return
}
}
})()
try {
const compact = await client.session.summarize(
{
sessionID: sessionId,
directory: this.options.defaultWorkspace,
providerID: configuredModel.providerID,
modelID: configuredModel.modelID,
auto: false
},
{ signal }
)
if (compact.error || compact.data !== true) {
throw new Error(
opencodeErrorMessage(
compact.error,
'OpenCode 原生 Compact 失败'
)
)
}
let graceTimer: ReturnType<typeof setTimeout> | undefined
try {
await Promise.race([
usageCapture,
new Promise<void>((resolveGrace) => {
graceTimer = setTimeout(
resolveGrace,
COMPACTION_USAGE_EVENT_GRACE_MS
)
graceTimer.unref?.()
})
])
} finally {
if (graceTimer) {
clearTimeout(graceTimer)
}
}
return {
result: {
provider: 'opencode',
strategy: 'native',
compacted: true,
detail: 'OpenCode 已完成原生上下文压缩'
},
...(usageEvents.length > 0 ? { usageEvents } : {})
}
} finally {
subscriptionController.abort()
await usageCapture.catch(() => undefined)
}
} finally {
releaseConversation?.()
releaseEmbedded()
+97
View File
@@ -922,6 +922,103 @@ describe.runIf(enabled)('runtime end-to-end', () => {
180_000
)
it(
'compacts and continues a real bundled OpenCode session',
async () => {
const runtime = new OpenCodeRuntime({
embedded: true,
binaryPath: '',
bundledBinaryPath: join(
portableRoot,
'resources',
'runtimes',
'opencode',
'opencode.exe'
),
configPath: '',
defaultWorkspace: workspace,
modelProfile: {
id: crypto.randomUUID(),
name: 'E2E model',
baseUrl,
modelName,
apiKey,
protocol,
authentication: 'api-key'
}
})
const conversationId = crypto.randomUUID()
const signal = new AbortController().signal
try {
await expect(
collectText(
runtime.run(
{
requestId: crypto.randomUUID(),
conversationId,
workMode: 'ask',
prompt:
'Remember that the verification codename is NATIVE-COMPACT-739. Reply with exactly OPENCODE_COMPACT_READY.'
},
signal
)
)
).resolves.toContain('OPENCODE_COMPACT_READY')
await expect(
runtime.compactConversation(
{
requestId: crypto.randomUUID(),
conversationId,
runtimeSelection: { provider: 'opencode' },
history: [
{
role: 'user',
content:
'The verification codename is NATIVE-COMPACT-739.'
},
{
role: 'assistant',
content: 'OPENCODE_COMPACT_READY'
}
],
historyMessageIds: [
crypto.randomUUID(),
crypto.randomUUID()
]
},
signal
)
).resolves.toMatchObject({
result: {
provider: 'opencode',
strategy: 'native',
compacted: true
}
})
await expect(
collectText(
runtime.run(
{
requestId: crypto.randomUUID(),
conversationId,
workMode: 'ask',
prompt:
'Return exactly the verification codename from before and nothing else.'
},
signal
)
)
).resolves.toContain('NATIVE-COMPACT-739')
} finally {
await runtime.dispose()
}
},
180_000
)
it(
'completes an Execute file task through bundled Continue',
async () => {
+227 -8
View File
@@ -1,23 +1,55 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { showDesktopNotificationWhenUnfocused } from './desktop-notification'
import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
registerDesktopNotificationActivation,
showDesktopNotificationWhenUnfocused
} from './desktop-notification'
const notificationMocks = vi.hoisted(() => ({
activationHandler: undefined as (() => void) | undefined,
handleActivation: vi.fn((callback: () => void) => {
notificationMocks.activationHandler = callback
}),
close: vi.fn(),
isSupported: vi.fn(() => true),
show: vi.fn()
show: vi.fn(),
instances: [] as Array<{
emit: (event: string, ...args: unknown[]) => boolean
listenerCount: (event: string) => number
}>
}))
vi.mock('electron', () => ({
Notification: class {
static isSupported = notificationMocks.isSupported
vi.mock('electron', async () => {
const { EventEmitter } = await import('node:events')
show = notificationMocks.show
return {
Notification: class extends EventEmitter {
static handleActivation = notificationMocks.handleActivation
static isSupported = notificationMocks.isSupported
close = notificationMocks.close
show = notificationMocks.show
constructor() {
super()
notificationMocks.instances.push(this)
}
}
}
}))
})
describe('showDesktopNotificationWhenUnfocused', () => {
beforeEach(() => {
vi.clearAllMocks()
notificationMocks.activationHandler = undefined
notificationMocks.isSupported.mockReturnValue(true)
notificationMocks.instances.length = 0
})
afterEach(() => {
for (const notification of notificationMocks.instances) {
notification.emit('close', {})
}
})
it('suppresses desktop notifications while GoodBuddy is focused', () => {
@@ -45,4 +77,191 @@ describe('showDesktopNotificationWhenUnfocused', () => {
expect(shown).toBe(true)
expect(notificationMocks.show).toHaveBeenCalledOnce()
})
it('registers Windows notification activation to restore GoodBuddy', () => {
const window = {
isDestroyed: vi.fn(() => false),
isMinimized: vi.fn(() => true),
restore: vi.fn(),
show: vi.fn(),
focus: vi.fn()
}
registerDesktopNotificationActivation(window as never, 'win32')
notificationMocks.activationHandler?.()
expect(notificationMocks.handleActivation).toHaveBeenCalledOnce()
expect(window.restore).toHaveBeenCalledOnce()
expect(window.show).toHaveBeenCalledOnce()
expect(window.focus).toHaveBeenCalledOnce()
})
it('waits for the renderer before showing a cold-start activation', () => {
const webContents = new EventEmitter() as EventEmitter & {
getURL: ReturnType<typeof vi.fn>
isLoadingMainFrame: ReturnType<typeof vi.fn>
}
webContents.getURL = vi.fn(() => '')
webContents.isLoadingMainFrame = vi.fn(() => true)
const window = {
isDestroyed: vi.fn(() => false),
isMinimized: vi.fn(() => false),
restore: vi.fn(),
show: vi.fn(),
focus: vi.fn(),
webContents
}
registerDesktopNotificationActivation(window as never, 'win32')
notificationMocks.activationHandler?.()
expect(window.show).not.toHaveBeenCalled()
webContents.getURL.mockReturnValue(
'file:///D:/goodbuddy/out/renderer/index.html'
)
webContents.isLoadingMainFrame.mockReturnValue(false)
webContents.emit('did-finish-load')
expect(window.show).toHaveBeenCalledOnce()
expect(window.focus).toHaveBeenCalledOnce()
})
it('does not register native activation outside Windows', () => {
registerDesktopNotificationActivation(
{
isDestroyed: vi.fn(() => false)
} as never,
'linux'
)
expect(notificationMocks.handleActivation).not.toHaveBeenCalled()
})
it('restores, shows, and focuses GoodBuddy when a notification is clicked', () => {
const window = {
isDestroyed: vi.fn(() => false),
isFocused: vi.fn(() => false),
isMinimized: vi.fn(() => true),
restore: vi.fn(),
show: vi.fn(),
focus: vi.fn()
}
showDesktopNotificationWhenUnfocused(window as never, {
title: '任务已完成'
})
notificationMocks.instances[0]!.emit('click', {})
expect(window.restore).toHaveBeenCalledOnce()
expect(window.show).toHaveBeenCalledOnce()
expect(window.focus).toHaveBeenCalledOnce()
expect(
notificationMocks.instances[0]!.listenerCount('click')
).toBe(0)
})
it('does not operate on a window destroyed before the click', () => {
const window = {
isDestroyed: vi
.fn()
.mockReturnValueOnce(false)
.mockReturnValue(true),
isFocused: vi.fn(() => false),
isMinimized: vi.fn(),
restore: vi.fn(),
show: vi.fn(),
focus: vi.fn()
}
showDesktopNotificationWhenUnfocused(window as never, {
title: '任务已完成'
})
notificationMocks.instances[0]!.emit('click', {})
expect(window.isMinimized).not.toHaveBeenCalled()
expect(window.restore).not.toHaveBeenCalled()
expect(window.show).not.toHaveBeenCalled()
expect(window.focus).not.toHaveBeenCalled()
})
it('does not let window activation errors escape the native callback', () => {
const window = {
isDestroyed: vi.fn(() => false),
isFocused: vi.fn(() => false),
isMinimized: vi.fn(() => false),
restore: vi.fn(),
show: vi.fn(() => {
throw new Error('window closed')
}),
focus: vi.fn()
}
showDesktopNotificationWhenUnfocused(window as never, {
title: '任务已完成'
})
expect(() =>
notificationMocks.instances[0]!.emit('click', {})
).not.toThrow()
expect(
notificationMocks.instances[0]!.listenerCount('click')
).toBe(0)
})
it.each(['close', 'failed'])(
'releases retained notifications after %s',
(event) => {
showDesktopNotificationWhenUnfocused(
{
isDestroyed: vi.fn(() => false),
isFocused: vi.fn(() => false)
} as never,
{ title: '任务已完成' }
)
const notification = notificationMocks.instances[0]!
notification.emit(event, {})
expect(notification.listenerCount('click')).toBe(0)
}
)
it('releases an unhandled notification after a bounded retention period', () => {
vi.useFakeTimers()
try {
showDesktopNotificationWhenUnfocused(
{
isDestroyed: vi.fn(() => false),
isFocused: vi.fn(() => false)
} as never,
{ title: '任务已完成' }
)
const notification = notificationMocks.instances[0]!
expect(notification.listenerCount('click')).toBe(1)
vi.advanceTimersByTime(15 * 60_000)
expect(notificationMocks.close).toHaveBeenCalledOnce()
expect(notification.listenerCount('click')).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('bounds retained notification instances during long-running sessions', () => {
for (let index = 0; index < 65; index += 1) {
showDesktopNotificationWhenUnfocused(
{
isDestroyed: vi.fn(() => false),
isFocused: vi.fn(() => false)
} as never,
{ title: `任务已完成 ${index}` }
)
}
expect(notificationMocks.close).toHaveBeenCalledOnce()
expect(notificationMocks.instances[0]!.listenerCount('click')).toBe(0)
expect(notificationMocks.instances[64]!.listenerCount('click')).toBe(1)
})
})
+92 -1
View File
@@ -3,6 +3,50 @@ import {
type BrowserWindow,
type NotificationConstructorOptions
} from 'electron'
import { showWindow } from './window'
const MAX_RETAINED_NOTIFICATIONS = 64
const NOTIFICATION_RETENTION_MS = 15 * 60_000
const activeNotifications = new Map<Notification, () => void>()
const pendingWindowActivations = new WeakSet<BrowserWindow>()
function activateWindow(window: BrowserWindow): void {
try {
if (window.isDestroyed()) {
return
}
const webContents = window.webContents
if (
webContents &&
(!webContents.getURL() ||
webContents.isLoadingMainFrame())
) {
if (!pendingWindowActivations.has(window)) {
pendingWindowActivations.add(window)
webContents.once('did-finish-load', () => {
pendingWindowActivations.delete(window)
activateWindow(window)
})
}
return
}
showWindow(window)
} catch {
// The window can be destroyed between checks while a native callback runs.
}
}
export function registerDesktopNotificationActivation(
window: BrowserWindow,
platform: NodeJS.Platform = process.platform
): void {
if (platform !== 'win32') {
return
}
Notification.handleActivation(() => {
activateWindow(window)
})
}
export function showDesktopNotificationWhenUnfocused(
window: BrowserWindow,
@@ -15,6 +59,53 @@ export function showDesktopNotificationWhenUnfocused(
) {
return false
}
new Notification(options).show()
const notification = new Notification(options)
let retentionTimer: ReturnType<typeof setTimeout> | undefined
const release = (): void => {
if (retentionTimer) {
clearTimeout(retentionTimer)
retentionTimer = undefined
}
activeNotifications.delete(notification)
notification.removeListener('click', handleClick)
notification.removeListener('close', release)
notification.removeListener('failed', release)
}
const dismiss = (): void => {
try {
notification.close()
} catch {
// The native notification may already have been dismissed.
} finally {
release()
}
}
const handleClick = (): void => {
try {
activateWindow(window)
} finally {
release()
}
}
if (activeNotifications.size >= MAX_RETAINED_NOTIFICATIONS) {
activeNotifications.values().next().value?.()
}
activeNotifications.set(notification, dismiss)
retentionTimer = setTimeout(dismiss, NOTIFICATION_RETENTION_MS)
retentionTimer.unref?.()
notification.once('click', handleClick)
notification.once('close', release)
notification.once('failed', release)
try {
notification.show()
} catch (error) {
release()
throw error
}
return true
}
+70 -4
View File
@@ -6,6 +6,7 @@ import {
Menu,
safeStorage,
session,
shell,
Tray,
utilityProcess
} from 'electron'
@@ -88,6 +89,12 @@ import {
DshNpmExtensionInstaller,
DshNpmMarketplaceCatalog
} from './agent/dsh-extension-marketplace'
import { registerDesktopNotificationActivation } from './desktop-notification'
import {
isInstalledWindowsBuild,
repairStaleWindowsNotificationShortcuts,
resolveWindowsAppUserModelId
} from './windows-notification-identity'
const shortcut = 'CommandOrControl+Shift+Space'
const mainModuleDirectory = dirname(fileURLToPath(import.meta.url))
@@ -99,8 +106,18 @@ const portableUserDataPath = resolvePortableUserDataPath({
if (portableUserDataPath) {
app.setPath('userData', portableUserDataPath)
}
const installedWindowsBuild = isInstalledWindowsBuild({
packaged: app.isPackaged,
platform: process.platform,
executablePath: process.execPath
})
if (process.platform === 'win32') {
app.setAppUserModelId('live.digiman.goodbuddy')
app.setAppUserModelId(
resolveWindowsAppUserModelId({
installed: installedWindowsBuild,
executablePath: process.execPath
})
)
}
const hasSingleInstanceLock = app.requestSingleInstanceLock()
@@ -122,6 +139,7 @@ let globalTlsPolicy: GlobalTlsPolicy | undefined
let documentOcrBroker: DocumentOcrBroker | undefined
let documentOcrModelManager: DocumentOcrModelManager | undefined
let stopRuntimeReconfiguration: (() => Promise<void>) | undefined
let dshExtensionInstaller: DshNpmExtensionInstaller | undefined
function createEmbeddingProvider(
settings: ResolvedRuntimeSettings
@@ -367,6 +385,7 @@ if (hasSingleInstanceLock) {
)
mainWindow = createMainWindow(() => isQuitting)
registerDesktopNotificationActivation(mainWindow)
tray = buildTray()
const defaultWorkspace = process.env.GOODBUDDY_WORKSPACE ?? homedir()
const secureCipher = {
@@ -452,7 +471,7 @@ if (hasSingleInstanceLock) {
app.getPath('userData'),
'deepseek-harness'
)
const dshExtensionInstaller = new DshNpmExtensionInstaller({
const startupDshExtensionInstaller = new DshNpmExtensionInstaller({
dshHome: deepSeekHarnessHome,
npmCliPath: app.isPackaged
? join(
@@ -470,11 +489,13 @@ if (hasSingleInstanceLock) {
'npm-cli.js'
)
})
dshExtensionInstaller = startupDshExtensionInstaller
const runtimeExtensionStore = new RuntimeExtensionStore(
app.getPath('userData'),
{
catalog: new DshNpmMarketplaceCatalog(),
install: (input) => dshExtensionInstaller.install(input)
install: (input) =>
startupDshExtensionInstaller.install(input)
}
)
const launchDeepSeekHarness =
@@ -713,6 +734,48 @@ if (hasSingleInstanceLock) {
runtimeExtensionStore
)
loadMainWindow(mainWindow)
setImmediate(() => {
void repairStaleWindowsNotificationShortcuts({
platform: process.platform,
installed: installedWindowsBuild,
executablePath: process.execPath,
programsDirectory: join(
app.getPath('appData'),
'Microsoft',
'Windows',
'Start Menu',
'Programs'
),
shortcutAccess: {
readShortcutLink: (shortcutPath) =>
shell.readShortcutLink(shortcutPath),
writeShortcutLink: (
shortcutPath,
operation,
options
) =>
shell.writeShortcutLink(
shortcutPath,
operation,
options
)
}
}).then(
({ failed }) => {
if (failed > 0) {
console.warn(
`Failed to repair ${failed} stale notification shortcut(s)`
)
}
},
(error: unknown) => {
console.warn(
'Failed to inspect stale notification shortcuts',
error
)
}
)
})
app.on('activate', () => {
if (mainWindow) {
@@ -744,7 +807,10 @@ app.on('before-quit', (event) => {
void (async () => {
try {
const cleanup = settleCleanupPhases([
[() => removeIpcHandlers?.()],
[
() => dshExtensionInstaller?.dispose(),
() => removeIpcHandlers?.()
],
[() => stopRuntimeReconfiguration?.()],
[
() => runtime?.dispose(),
+46 -2
View File
@@ -2151,6 +2151,9 @@ describe('registerIpcHandlers Runtime customization', () => {
state: 'complete' as const
}
]
let persistedRuntimeSelection:
| { provider: 'opencode' }
| undefined = { provider: 'opencode' }
const assistantDatabase = {
claimDueSchedules: vi.fn(() => []),
getProject: vi.fn(() => ({
@@ -2160,7 +2163,7 @@ describe('registerIpcHandlers Runtime customization', () => {
getConversation: vi.fn(() => ({
id: conversationId,
projectId,
runtimeSelection: { provider: 'opencode' as const },
runtimeSelection: persistedRuntimeSelection,
title: 'Runtime conversation',
updatedAt: Date.now(),
messages
@@ -2290,13 +2293,54 @@ describe('registerIpcHandlers Runtime customization', () => {
)?.(event, {
...compactInput,
requestId: '00000000-0000-4000-8000-000000000606',
runtimeSelection: { provider: 'continue' }
})
).rejects.toThrow('对话 Runtime 或 Project 已更改')
await expect(
electronMocks.handlers.get(
ipcChannels.agentCompactConversation
)?.(event, {
...compactInput,
requestId: '00000000-0000-4000-8000-000000000607',
projectId: '00000000-0000-4000-8000-000000000608'
})
).rejects.toThrow('对话 Runtime 或 Project 已更改')
persistedRuntimeSelection = undefined
await expect(
electronMocks.handlers.get(
ipcChannels.agentCompactConversation
)?.(event, {
...compactInput,
requestId: '00000000-0000-4000-8000-000000000609'
})
).resolves.toMatchObject({
provider: 'opencode',
compacted: true
})
await expect(
electronMocks.handlers.get(
ipcChannels.agentCompactConversation
)?.(event, {
...compactInput,
requestId: '00000000-0000-4000-8000-000000000610',
runtimeSelection: { provider: 'continue' }
})
).rejects.toThrow('对话 Runtime 或 Project 已更改')
await expect(
electronMocks.handlers.get(
ipcChannels.agentCompactConversation
)?.(event, {
...compactInput,
requestId: '00000000-0000-4000-8000-000000000611',
history: [
compactInput.history[0],
{ role: 'assistant', content: 'stale content' }
]
})
).rejects.toThrow('对话历史已更改')
expect(selectedRuntimes.compactConversation).toHaveBeenCalledOnce()
expect(selectedRuntimes.compactConversation).toHaveBeenCalledTimes(2)
await dispose()
})
})
+6 -3
View File
@@ -127,6 +127,7 @@ import {
import {
agentRuntimeSelectionKey,
agentRuntimeSelectionSchema,
getDefaultRuntimeSelection,
type AgentRuntimeSelection
} from '../shared/runtime-selection-contracts'
import {
@@ -2968,10 +2969,13 @@ export function registerIpcHandlers(
const conversation = assistantDatabase.getConversation(
request.conversationId
)
const settings = await settingsStore.getResolvedSettings()
const persistedRuntimeSelection =
conversation.runtimeSelection ??
getDefaultRuntimeSelection(settings)
if (
conversation.projectId !== request.projectId ||
!conversation.runtimeSelection ||
agentRuntimeSelectionKey(conversation.runtimeSelection) !==
agentRuntimeSelectionKey(persistedRuntimeSelection) !==
agentRuntimeSelectionKey(request.runtimeSelection)
) {
throw new Error('对话 Runtime 或 Project 已更改,请刷新后重试')
@@ -2998,7 +3002,6 @@ export function registerIpcHandlers(
contextCompressionState:
conversation.contextCompressionState
}
const settings = await settingsStore.getResolvedSettings()
const selected = applyRuntimeSelection(
settings,
request.runtimeSelection
+45 -6
View File
@@ -17,12 +17,51 @@ describe('packaged release notes', () => {
expect.objectContaining({ version: '0.8.19' })
)
for (const release of parsed.releases) {
expect(release.notes['zh-CN'].features).toHaveLength(
release.notes['en-US'].features.length
)
expect(release.notes['zh-CN'].fixes).toHaveLength(
release.notes['en-US'].fixes.length
)
for (const section of [
'highlights',
'features',
'fixes',
'notices'
] as const) {
expect(release.notes['zh-CN'][section]).toHaveLength(
release.notes['en-US'][section].length
)
}
}
const currentRelease = parsed.releases.find(
(release) => release.version === '0.10.0'
)
expect(currentRelease).toBeDefined()
expect(currentRelease?.releasedAt).toBe('2026-08-17')
expect(currentRelease?.notes['zh-CN'].highlights).toHaveLength(1)
expect(currentRelease?.notes['zh-CN'].features).toHaveLength(7)
expect(currentRelease?.notes['zh-CN'].fixes).toHaveLength(6)
expect(currentRelease?.notes['zh-CN'].notices).toHaveLength(4)
expect(
currentRelease?.notes['zh-CN'].features.every((item) =>
item.startsWith('**')
)
).toBe(true)
expect(
currentRelease?.notes['zh-CN'].fixes.every((item) =>
item.startsWith('**')
)
).toBe(true)
expect(
currentRelease?.notes['zh-CN'].notices.every((item) =>
item.startsWith('**')
)
).toBe(true)
expect(JSON.stringify(currentRelease)).not.toContain('官网')
expect(JSON.stringify(currentRelease)).not.toContain(
'Website and product preview'
)
const legacyRelease = parsed.releases.find(
(release) => release.version === '0.9.3'
)
expect(legacyRelease?.notes['zh-CN'].highlights).toEqual([])
expect(legacyRelease?.notes['zh-CN'].notices).toEqual([])
})
})
@@ -0,0 +1,194 @@
import {
mkdtemp,
rm,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { ShortcutDetails } from 'electron'
import {
afterEach,
describe,
expect,
it,
vi
} from 'vitest'
import {
isInstalledWindowsBuild,
repairStaleWindowsNotificationShortcuts,
resolveWindowsAppUserModelId,
WINDOWS_APP_USER_MODEL_ID
} from './windows-notification-identity'
const temporaryDirectories: string[] = []
async function createTemporaryDirectory(): Promise<string> {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-notification-identity-')
)
temporaryDirectories.push(directory)
return directory
}
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
describe('Windows notification identity', () => {
it('reserves the production AUMID for an installed build', () => {
expect(
resolveWindowsAppUserModelId({
installed: true,
executablePath: 'C:\\Program Files\\GoodBuddy\\GoodBuddy.exe'
})
).toBe(WINDOWS_APP_USER_MODEL_ID)
})
it('uses stable path-scoped identities for standalone builds', () => {
const first = resolveWindowsAppUserModelId({
installed: false,
executablePath: 'D:\\repo\\GoodBuddy.exe'
})
expect(first).not.toBe(WINDOWS_APP_USER_MODEL_ID)
expect(
resolveWindowsAppUserModelId({
installed: false,
executablePath: 'd:\\REPO\\goodbuddy.exe'
})
).toBe(first)
expect(
resolveWindowsAppUserModelId({
installed: false,
executablePath: 'D:\\other\\GoodBuddy.exe'
})
).not.toBe(first)
})
it('recognizes only packaged Windows layouts with an uninstaller', async () => {
const directory = await createTemporaryDirectory()
const executablePath = join(directory, 'GoodBuddy.exe')
await writeFile(join(directory, 'Uninstall GoodBuddy.exe'), '')
expect(
isInstalledWindowsBuild({
packaged: true,
platform: 'win32',
executablePath
})
).toBe(true)
expect(
isInstalledWindowsBuild({
packaged: false,
platform: 'win32',
executablePath
})
).toBe(false)
expect(
isInstalledWindowsBuild({
packaged: true,
platform: 'linux',
executablePath
})
).toBe(false)
expect(
isInstalledWindowsBuild({
packaged: true,
platform: 'win32',
executablePath: '\0'
})
).toBe(false)
})
it('moves stale shortcuts off the production identity without changing their targets', async () => {
const programsDirectory = await createTemporaryDirectory()
const stalePath = join(programsDirectory, 'Electron.lnk')
const currentPath = join(programsDirectory, 'GoodBuddy.lnk')
await Promise.all([
writeFile(stalePath, ''),
writeFile(currentPath, '')
])
const staleDetails: ShortcutDetails = {
target: 'D:\\repo\\node_modules\\electron\\electron.exe',
appUserModelId: WINDOWS_APP_USER_MODEL_ID,
toastActivatorClsid:
'{6D4C974B-001A-47E5-AE4B-F8F12FFDA281}'
}
const details = new Map<string, ShortcutDetails>([
[stalePath, staleDetails],
[
currentPath,
{
target:
'C:\\Program Files\\GoodBuddy\\GoodBuddy.exe',
appUserModelId: WINDOWS_APP_USER_MODEL_ID
}
]
])
const writeShortcutLink = vi.fn(() => true)
await expect(
repairStaleWindowsNotificationShortcuts({
platform: 'win32',
installed: true,
executablePath:
'C:\\Program Files\\GoodBuddy\\GoodBuddy.exe',
programsDirectory,
shortcutAccess: {
readShortcutLink: (shortcutPath) =>
details.get(shortcutPath)!,
writeShortcutLink
}
})
).resolves.toEqual({
scanned: 2,
repaired: 1,
failed: 0
})
expect(writeShortcutLink).toHaveBeenCalledOnce()
expect(writeShortcutLink).toHaveBeenCalledWith(
stalePath,
'update',
expect.objectContaining({
target: staleDetails.target,
toastActivatorClsid: staleDetails.toastActivatorClsid,
appUserModelId: expect.not.stringMatching(
/^live\.digiman\.goodbuddy$/u
)
})
)
})
it('does not touch shortcuts from development or portable builds', async () => {
const programsDirectory = await createTemporaryDirectory()
await writeFile(join(programsDirectory, 'Electron.lnk'), '')
const readShortcutLink = vi.fn()
const writeShortcutLink = vi.fn()
await expect(
repairStaleWindowsNotificationShortcuts({
platform: 'win32',
installed: false,
executablePath: 'D:\\repo\\GoodBuddy.exe',
programsDirectory,
shortcutAccess: {
readShortcutLink,
writeShortcutLink
}
})
).resolves.toEqual({
scanned: 0,
repaired: 0,
failed: 0
})
expect(readShortcutLink).not.toHaveBeenCalled()
expect(writeShortcutLink).not.toHaveBeenCalled()
})
})
+133
View File
@@ -0,0 +1,133 @@
import { createHash } from 'node:crypto'
import { statSync } from 'node:fs'
import { dirname, join, resolve, win32 } from 'node:path'
import type { ShortcutDetails } from 'electron'
export const WINDOWS_APP_USER_MODEL_ID = 'live.digiman.goodbuddy'
const windowsUninstallerName = 'Uninstall GoodBuddy.exe'
const standaloneIdentityPrefix = `${WINDOWS_APP_USER_MODEL_ID}.standalone`
const knownShortcutNames = ['Electron.lnk', 'GoodBuddy.lnk'] as const
interface ShortcutAccess {
readShortcutLink(shortcutPath: string): ShortcutDetails
writeShortcutLink(
shortcutPath: string,
operation: 'update',
options: ShortcutDetails
): boolean
}
export interface WindowsNotificationShortcutRepairResult {
scanned: number
repaired: number
failed: number
}
function normalizeWindowsExecutablePath(executablePath: string): string {
return win32.resolve(executablePath).toLocaleLowerCase('en-US')
}
function resolveStandaloneWindowsAppUserModelId(
executablePath: string
): string {
const executableHash = createHash('sha256')
.update(normalizeWindowsExecutablePath(executablePath))
.digest('hex')
.slice(0, 24)
return `${standaloneIdentityPrefix}.${executableHash}`
}
export function isInstalledWindowsBuild(input: {
packaged: boolean
platform: NodeJS.Platform
executablePath: string
}): boolean {
if (!input.packaged || input.platform !== 'win32') {
return false
}
try {
const uninstallerPath = join(
dirname(resolve(input.executablePath)),
windowsUninstallerName
)
return statSync(uninstallerPath, {
throwIfNoEntry: false
})?.isFile() === true
} catch {
return false
}
}
export function resolveWindowsAppUserModelId(input: {
installed: boolean
executablePath: string
}): string {
return input.installed
? WINDOWS_APP_USER_MODEL_ID
: resolveStandaloneWindowsAppUserModelId(input.executablePath)
}
export async function repairStaleWindowsNotificationShortcuts(input: {
platform: NodeJS.Platform
installed: boolean
executablePath: string
programsDirectory: string
shortcutAccess: ShortcutAccess
}): Promise<WindowsNotificationShortcutRepairResult> {
if (input.platform !== 'win32' || !input.installed) {
return { scanned: 0, repaired: 0, failed: 0 }
}
const shortcutPaths = knownShortcutNames.map((shortcutName) =>
join(input.programsDirectory, shortcutName)
)
const currentExecutablePath = normalizeWindowsExecutablePath(
input.executablePath
)
let scanned = 0
let repaired = 0
let failed = 0
for (const shortcutPath of shortcutPaths) {
let details: ShortcutDetails
try {
details = input.shortcutAccess.readShortcutLink(shortcutPath)
} catch {
continue
}
scanned += 1
if (
details.appUserModelId !== WINDOWS_APP_USER_MODEL_ID ||
normalizeWindowsExecutablePath(details.target) ===
currentExecutablePath
) {
continue
}
try {
const updated = input.shortcutAccess.writeShortcutLink(
shortcutPath,
'update',
{
...details,
appUserModelId:
resolveStandaloneWindowsAppUserModelId(details.target)
}
)
if (updated) {
repaired += 1
} else {
failed += 1
}
} catch {
failed += 1
}
}
return {
scanned,
repaired,
failed
}
}
+14 -6
View File
@@ -948,10 +948,14 @@ describe('App', () => {
expect(loading).toHaveAttribute('aria-busy', 'true')
await act(async () => lazyRouteMocks.releaseKnowledgeRoute())
expect(
await screen.findByRole('heading', {
level: 1,
name: '知识库'
})
await screen.findByRole(
'heading',
{
level: 1,
name: '知识库'
},
{ timeout: 3000 }
)
).toBeInTheDocument()
expect(
screen.queryByRole('status', { name: '正在加载页面…' })
@@ -1208,12 +1212,16 @@ describe('App', () => {
releasedAt: '2026-08-11',
notes: {
'zh-CN': {
highlights: ['多 Runtime 工作流更加连贯'],
features: ['新增版本更新说明'],
fixes: ['修复重复显示']
fixes: ['修复重复显示'],
notices: ['Ask 模式保持只读']
},
'en-US': {
highlights: ['Multi-Runtime workflows are more cohesive'],
features: ['Added release notes'],
fixes: ['Fixed repeated display']
fixes: ['Fixed repeated display'],
notices: ['Ask remains read-only']
}
}
}
+19 -1
View File
@@ -15,7 +15,10 @@ import {
vi
} from 'vitest'
import { changeUiLocale } from './i18n'
import { MarkdownRenderer } from './MarkdownRenderer'
import {
InlineMarkdown,
MarkdownRenderer
} from './MarkdownRenderer'
const mermaidMock = vi.hoisted(() => ({
initialize: vi.fn(),
@@ -41,6 +44,21 @@ describe('MarkdownRenderer', () => {
vi.clearAllMocks()
})
it('renders bounded inline emphasis without links or raw HTML', () => {
const { container } = render(
<p>
<InlineMarkdown>
{'**明确标题。** 查看[外部页面](https://example.com)。<script>bad()</script>'}
</InlineMarkdown>
</p>
)
expect(screen.getByText('明确标题。').tagName).toBe('STRONG')
expect(container).toHaveTextContent('外部页面')
expect(container.querySelector('a')).not.toBeInTheDocument()
expect(container.querySelector('script')).not.toBeInTheDocument()
})
it('renders CommonMark and GitHub Flavored Markdown', () => {
render(
<MarkdownRenderer>{`# 标题
+19
View File
@@ -92,6 +92,25 @@ type MarkdownRendererProps = {
children: string
}
const inlineMarkdownComponents: Components = {
p: ({ children }) => <>{children}</>
}
export const InlineMarkdown = memo(function InlineMarkdown({
children
}: MarkdownRendererProps): React.JSX.Element {
return (
<ReactMarkdown
allowedElements={['p', 'strong']}
components={inlineMarkdownComponents}
skipHtml
unwrapDisallowed
>
{children}
</ReactMarkdown>
)
})
const wholeMarkdownFence =
/^```(?:markdown|md)\s*\r?\n([\s\S]*?)\r?\n```$/iu
+43 -8
View File
@@ -19,12 +19,18 @@ const snapshot: ReleaseNotesSnapshot = {
releasedAt: '2026-08-11',
notes: {
'zh-CN': {
features: ['新增双语界面'],
fixes: ['修复开关尺寸']
highlights: ['多 Runtime 工作流更加连贯。'],
features: ['**Runtime 能力概览。** 查看实际可用能力。'],
fixes: ['**设置界面一致性。** 修复开关尺寸。'],
notices: ['**工作模式权限。** Ask 模式保持只读。']
},
'en-US': {
features: ['Added a bilingual interface'],
fixes: ['Fixed switch dimensions']
highlights: ['Multi-Runtime workflows are more cohesive.'],
features: [
'**Runtime capability overview.** View actual capabilities.'
],
fixes: ['**Settings consistency.** Fixed switch dimensions.'],
notices: ['**Work mode permissions.** Ask remains read-only.']
}
}
}
@@ -71,8 +77,26 @@ describe('ReleaseNotesDialog', () => {
name: 'GoodBuddy 0.8.18 更新内容'
})
).toBeInTheDocument()
expect(screen.getByText('新增双语界面')).toBeInTheDocument()
expect(screen.getByText('修复开关尺寸')).toBeInTheDocument()
expect(
screen.getByRole('heading', { name: '本次亮点' })
).toBeInTheDocument()
expect(
screen.getByText('多 Runtime 工作流更加连贯。')
).toBeInTheDocument()
expect(
screen.getByRole('heading', { name: '功能更新' })
).toBeInTheDocument()
expect(screen.getByText('Runtime 能力概览。').tagName).toBe(
'STRONG'
)
expect(screen.getByText('查看实际可用能力。')).toBeInTheDocument()
expect(
screen.getByRole('heading', { name: '问题修复' })
).toBeInTheDocument()
expect(
screen.getByRole('heading', { name: '使用前请留意' })
).toBeInTheDocument()
expect(screen.getByText('工作模式权限。').tagName).toBe('STRONG')
expect(screen.queryByRole('link')).not.toBeInTheDocument()
expect(
container.querySelector<HTMLElement>('.app-shell')?.inert
@@ -115,9 +139,20 @@ describe('ReleaseNotesDialog', () => {
})
).toBeInTheDocument()
expect(
screen.getByText('Added a bilingual interface')
screen.getByRole('heading', { name: 'Highlights' })
).toBeInTheDocument()
expect(screen.getByText('Fixed switch dimensions')).toBeInTheDocument()
expect(
screen.getByText('Multi-Runtime workflows are more cohesive.')
).toBeInTheDocument()
expect(
screen.getByText('Runtime capability overview.').tagName
).toBe('STRONG')
expect(
screen.getByRole('heading', { name: 'Before You Start' })
).toBeInTheDocument()
expect(screen.getByText('Work mode permissions.').tagName).toBe(
'STRONG'
)
expect(
screen.getByRole('button', { name: 'Get Started' })
).toBeInTheDocument()
+75 -28
View File
@@ -1,4 +1,10 @@
import { Sparkles, Wrench, X } from 'lucide-react'
import {
Lightbulb,
Sparkles,
TriangleAlert,
Wrench,
X
} from 'lucide-react'
import { useEffect, useId, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
@@ -8,6 +14,7 @@ import type {
} from '../../shared/release-notes-contracts'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import type { UiLocale } from './i18n'
import { InlineMarkdown } from './MarkdownRenderer'
type ReleaseNotesDialogProps = {
locale: UiLocale
@@ -16,6 +23,47 @@ type ReleaseNotesDialogProps = {
onClose: () => void
}
function ReleaseNotesSection({
heading,
headingLevel,
icon,
items,
variant
}: {
heading: string
headingLevel: 'h3' | 'h4'
icon: React.ReactNode
items: string[]
variant?: 'notices'
}): React.JSX.Element | null {
if (items.length === 0) {
return null
}
const SectionHeading = headingLevel
return (
<div
className={[
'release-notes-dialog__section',
variant && `release-notes-dialog__section--${variant}`
]
.filter(Boolean)
.join(' ')}
>
<SectionHeading>
{icon}
{heading}
</SectionHeading>
<ul>
{items.map((item) => (
<li key={item}>
<InlineMarkdown>{item}</InlineMarkdown>
</li>
))}
</ul>
</div>
)
}
function ReleaseSection({
locale,
release,
@@ -28,7 +76,7 @@ function ReleaseSection({
const { t } = useTranslation('app')
const notes = release.notes[locale]
const releaseHeadingId = useId()
const SectionHeading = showVersion ? 'h4' : 'h3'
const headingLevel = showVersion ? 'h4' : 'h3'
return (
<section
aria-labelledby={showVersion ? releaseHeadingId : undefined}
@@ -42,32 +90,31 @@ function ReleaseSection({
GoodBuddy {release.version}
</h3>
)}
{notes.features.length > 0 && (
<div className="release-notes-dialog__section">
<SectionHeading>
<Sparkles aria-hidden="true" size={16} />
{t('releaseNotes.features')}
</SectionHeading>
<ul>
{notes.features.map((feature) => (
<li key={feature}>{feature}</li>
))}
</ul>
</div>
)}
{notes.fixes.length > 0 && (
<div className="release-notes-dialog__section">
<SectionHeading>
<Wrench aria-hidden="true" size={16} />
{t('releaseNotes.fixes')}
</SectionHeading>
<ul>
{notes.fixes.map((fix) => (
<li key={fix}>{fix}</li>
))}
</ul>
</div>
)}
<ReleaseNotesSection
heading={t('releaseNotes.highlights')}
headingLevel={headingLevel}
icon={<Lightbulb aria-hidden="true" size={16} />}
items={notes.highlights}
/>
<ReleaseNotesSection
heading={t('releaseNotes.features')}
headingLevel={headingLevel}
icon={<Sparkles aria-hidden="true" size={16} />}
items={notes.features}
/>
<ReleaseNotesSection
heading={t('releaseNotes.fixes')}
headingLevel={headingLevel}
icon={<Wrench aria-hidden="true" size={16} />}
items={notes.fixes}
/>
<ReleaseNotesSection
heading={t('releaseNotes.notices')}
headingLevel={headingLevel}
icon={<TriangleAlert aria-hidden="true" size={16} />}
items={notes.notices}
variant="notices"
/>
</section>
)
}
@@ -659,7 +659,7 @@ export const RuntimeCustomizationSection = forwardRef<
</p>
) : null}
{snapshot ? (
{snapshot && snapshot.inventoryStatus !== 'available' ? (
<NativeInventoryStatus snapshot={snapshot} />
) : null}
+31 -7
View File
@@ -2072,14 +2072,9 @@ describe('SettingsPanel runtime files', () => {
const agent = await screen.findByLabelText('默认 Agent')
expect(agent).toHaveValue('planner')
const nativeStatus = screen.getByRole('status')
expect(nativeStatus).toHaveTextContent('OpenCode 原生能力已就绪')
expect(
Boolean(
nativeStatus.compareDocumentPosition(agent) &
Node.DOCUMENT_POSITION_FOLLOWING
)
).toBe(true)
screen.queryByText('OpenCode 原生能力已就绪')
).not.toBeInTheDocument()
expect(screen.getByText('能力与默认配置')).toBeInTheDocument()
expect(screen.queryByText('Runtime 原生能力')).not.toBeInTheDocument()
expect(screen.queryByText('OpenCode 默认 Agent')).not.toBeInTheDocument()
@@ -2162,6 +2157,35 @@ describe('SettingsPanel runtime files', () => {
)
})
it.each([
['opencode', 'OpenCode 原生能力已就绪'],
[
'continue',
'内置 Continue CLI 已就绪;Rules 与 Prompts 来自原始静态配置;MCP Prompt 仅在 MCPService 运行并连接后可发现,非运行快照不会启动服务器。Continue MCPService 不提供 Resources。'
],
[
'deepseek-harness',
'显示 DeepSeek Harness Host 与插件原生能力;GoodBuddy 分配的 Skill 和 MCP 不在此清单中。'
]
] as const)(
'hides the redundant ready detail for %s',
async (provider, detail) => {
const fallbackSnapshot = await getRuntimeNativeSnapshot({
provider
})
getRuntimeNativeSnapshot.mockResolvedValueOnce({
...fallbackSnapshot,
detail
})
render(<RuntimeCustomizationSection provider={provider} />)
await screen.findByRole('tablist', { name: '能力清单' })
expect(screen.queryByText(detail)).not.toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
}
)
it('distinguishes external OpenCode connectivity from readable native inventory', async () => {
const fallbackSnapshot = await getRuntimeNativeSnapshot({
provider: 'opencode'
@@ -188,6 +188,12 @@ describe('WorkspacePrimitives', () => {
)
})
it('keeps DSH marketplace search text clear of its icon', () => {
expect(stylesheet).toMatch(
/\.field \.runtime-extension-marketplace__search-input > input\s*\{[^}]*padding-left:\s*34px;/u
)
})
it('separates model service fields from credential status', () => {
expect(stylesheet).toMatch(
/\.model-service-form\s*\{[^}]*display:\s*grid;[^}]*gap:\s*var\(--space-3\);/u
+3 -2
View File
@@ -15,10 +15,11 @@ export const app = {
releaseNotes: {
eyebrow: 'VERSION UPDATE',
title: "What's New in GoodBuddy {{version}}",
description:
'This release includes the following features and bug fixes.',
description: 'Review the key changes and usage notes in this release.',
highlights: 'Highlights',
features: 'Features',
fixes: 'Bug Fixes',
notices: 'Before You Start',
close: 'Close release notes',
start: 'Get Started',
closing: 'Closing…',
+3 -1
View File
@@ -12,9 +12,11 @@ export const app = {
releaseNotes: {
eyebrow: '版本更新',
title: 'GoodBuddy {{version}} 更新内容',
description: '本次版本带来了以下功能更新与问题修复。',
description: '查看本次版本的主要更新与使用提示。',
highlights: '本次亮点',
features: '功能更新',
fixes: '问题修复',
notices: '使用前请留意',
close: '关闭版本更新说明',
start: '开始使用',
closing: '正在关闭…',
+4 -41
View File
@@ -1,41 +1,4 @@
import type { RuntimeSettings } from '../../shared/contracts'
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
export function getRuntimeSelectionForProvider(
provider: 'model' | 'opencode' | 'continue' | 'deepseek-harness',
settings: RuntimeSettings
): AgentRuntimeSelection {
if (provider === 'model') {
return {
provider,
profileId: settings.defaultModelProfileId
}
}
const source =
provider === 'opencode'
? settings.opencodeModelSource
: provider === 'continue'
? settings.continueModelSource
: settings.deepseekHarnessModelSource ?? { kind: 'platform' }
return {
provider,
...(source.kind === 'profile' ? { profileId: source.profileId } : {})
}
}
export function getDefaultRuntimeSelection(
settings: RuntimeSettings
): AgentRuntimeSelection {
const provider = settings.provider
if (
provider === 'model' ||
provider === 'opencode' ||
provider === 'continue' ||
provider === 'deepseek-harness'
) {
return getRuntimeSelectionForProvider(provider, settings)
}
return settings.opencodeBaseUrl || settings.opencodeEmbedded
? getRuntimeSelectionForProvider('opencode', settings)
: getRuntimeSelectionForProvider('model', settings)
}
export {
getDefaultRuntimeSelection,
getRuntimeSelectionForProvider
} from '../../shared/runtime-selection-contracts'
+11 -6
View File
@@ -4373,6 +4373,16 @@ button > svg {
color: var(--accent);
}
.release-notes-dialog__section li strong {
color: var(--text-primary);
font-weight: 600;
}
.release-notes-dialog__section--notices :is(h3, h4) svg,
.release-notes-dialog__section--notices li::marker {
color: var(--warning);
}
.release-notes-dialog__footer {
display: flex;
align-items: center;
@@ -5496,11 +5506,6 @@ button > svg {
overflow-wrap: anywhere;
}
.runtime-native-inventory__status--available {
border-color: color-mix(in srgb, var(--success) 35%, transparent);
background: var(--success-subtle);
}
.runtime-native-inventory__status--unavailable {
border-color: var(--danger-border);
background: var(--danger-subtle);
@@ -5678,7 +5683,7 @@ button > svg {
transform: translateY(-50%);
}
.runtime-extension-marketplace__search-input > input {
.field .runtime-extension-marketplace__search-input > input {
padding-left: 34px;
}
+51 -21
View File
@@ -7,16 +7,40 @@ export const releaseVersionSchema = z
'Release version must be a stable semantic version'
)
const localizedReleaseNotesSchema = z
.object({
features: z.array(z.string().trim().min(1).max(240)).max(20),
fixes: z.array(z.string().trim().min(1).max(240)).max(20)
})
.strict()
.refine(
(notes) => notes.features.length > 0 || notes.fixes.length > 0,
'Release notes must contain at least one item'
)
const releaseNoteItemSchema = z.string().trim().min(1).max(500)
const localizedReleaseNotesSchema = z.preprocess(
(value) => {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return value
}
const notes = value as Record<string, unknown>
if ('highlights' in notes || 'notices' in notes) {
return value
}
return {
highlights: [],
...notes,
notices: []
}
},
z
.object({
highlights: z.array(releaseNoteItemSchema).max(3),
features: z.array(releaseNoteItemSchema).max(20),
fixes: z.array(releaseNoteItemSchema).max(20),
notices: z.array(releaseNoteItemSchema).max(20)
})
.strict()
.refine(
(notes) =>
notes.highlights.length > 0 ||
notes.features.length > 0 ||
notes.fixes.length > 0 ||
notes.notices.length > 0,
'Release notes must contain at least one item'
)
)
export const releaseNoteSchema = z
.object({
@@ -48,17 +72,23 @@ export const releaseNotesFileSchema = z
})
}
versions.add(release.version)
if (
release.notes['zh-CN'].features.length !==
release.notes['en-US'].features.length ||
release.notes['zh-CN'].fixes.length !==
release.notes['en-US'].fixes.length
) {
context.addIssue({
code: 'custom',
message: 'Localized release-note sections must have matching counts',
path: ['releases', index, 'notes']
})
for (const section of [
'highlights',
'features',
'fixes',
'notices'
] as const) {
if (
release.notes['zh-CN'][section].length !==
release.notes['en-US'][section].length
) {
context.addIssue({
code: 'custom',
message:
'Localized release-note sections must have matching counts',
path: ['releases', index, 'notes', section]
})
}
}
}
})
+61
View File
@@ -58,8 +58,69 @@ export type RuntimeSelectionRepairSettings = {
| { kind: 'profile'; profileId: string }
}
type RuntimeModelSource =
| { kind: 'platform' }
| { kind: 'profile'; profileId: string }
export type RuntimeSelectionDefaultSettings = {
provider: AgentRuntimeSelection['provider']
defaultModelProfileId: string
opencodeBaseUrl: string
opencodeEmbedded: boolean
opencodeModelSource?: RuntimeModelSource
continueModelSource?: RuntimeModelSource
deepseekHarnessModelSource?: RuntimeModelSource
opencodeModelProfile?: { id: string }
continueModelProfile?: { id: string }
deepseekHarnessModelProfile?: { id: string }
}
type ChannelModelProfile = RuntimeSelectionRepairSettings['modelProfiles'][number]
export function getRuntimeSelectionForProvider(
provider: Exclude<AgentRuntimeSelection['provider'], 'auto'>,
settings: RuntimeSelectionDefaultSettings
): AgentRuntimeSelection {
if (provider === 'model') {
return {
provider,
profileId: settings.defaultModelProfileId
}
}
const source =
provider === 'opencode'
? settings.opencodeModelSource
: provider === 'continue'
? settings.continueModelSource
: settings.deepseekHarnessModelSource
const resolvedProfile =
provider === 'opencode'
? settings.opencodeModelProfile
: provider === 'continue'
? settings.continueModelProfile
: settings.deepseekHarnessModelProfile
return {
provider,
...(source?.kind === 'profile'
? { profileId: source.profileId }
: !source && resolvedProfile
? { profileId: resolvedProfile.id }
: {})
}
}
export function getDefaultRuntimeSelection(
settings: RuntimeSelectionDefaultSettings
): AgentRuntimeSelection {
const provider = settings.provider
if (provider !== 'auto') {
return getRuntimeSelectionForProvider(provider, settings)
}
return settings.opencodeBaseUrl || settings.opencodeEmbedded
? getRuntimeSelectionForProvider('opencode', settings)
: getRuntimeSelectionForProvider('model', settings)
}
export function isChannelModelProfileUsable(
profile: ChannelModelProfile
): boolean {