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:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 插件安装器正在关闭'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>{`# 标题
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -12,9 +12,11 @@ export const app = {
|
||||
releaseNotes: {
|
||||
eyebrow: '版本更新',
|
||||
title: 'GoodBuddy {{version}} 更新内容',
|
||||
description: '本次版本带来了以下功能更新与问题修复。',
|
||||
description: '查看本次版本的主要更新与使用提示。',
|
||||
highlights: '本次亮点',
|
||||
features: '功能更新',
|
||||
fixes: '问题修复',
|
||||
notices: '使用前请留意',
|
||||
close: '关闭版本更新说明',
|
||||
start: '开始使用',
|
||||
closing: '正在关闭…',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user