chore: prepare GoodBuddy 0.8.6
Cross-platform packages / Validate source (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / Publish GitHub Release (push) Has been cancelled

This commit is contained in:
lofyer
2026-08-07 13:11:45 +08:00
parent 32aba176c8
commit 2c715e5e81
40 changed files with 3079 additions and 358 deletions
+71 -5
View File
@@ -77,7 +77,18 @@ async function createDistribution(version = '1.5.47'): Promise<{
'listen(i,async()=>{console.log(Ht.green(`Server started on http://localhost:${i}`))',
'async function SCt(e){return n5e||',
'shouldUseResponsesEndpoint(t){return this.config.useResponsesApi===!1?!1:this.apiBase==="https://api.openai.com/v1/"&&A0e(t)}',
'function uAe(e,t){let n={provider:e.provider,model:e.model,apiKey:e.apiKey,apiBase:e.apiBase,requestOptions:e.requestOptions,env:e.env};return CGn(n)??null}'
'function uAe(e,t){let n={provider:e.provider,model:e.model,apiKey:e.apiKey,apiBase:e.apiBase,requestOptions:e.requestOptions,env:e.env};return CGn(n)??null}',
'function Csa(e){return process.platform==="win32"?{shell:"powershell.exe",args:["-NoLogo","-ExecutionPolicy","Bypass","-Command",e]}',
'a={onContent:u=>{},onContentComplete:u=>{},onToolStart:(u,l)=>{},onToolResult:(u,l,c)=>{},onToolError:(u,l)=>{},onToolPermissionRequest:',
'pendingPermission:null},B=',
'j.get("/state",(we,Te)=>{M.lastActivity=Date.now(),B();let ue=e7e(M.session,M.isProcessing,rS.getQueueLength(),M.pendingPermission);Te.json(ue)})',
'n?.onToolStart?.(i.name,i.arguments);',
'n?.onToolError?.(l,i.name)',
't?.onToolStart?.(c.name,c.arguments);',
't?.onToolResult?.(String(y.content),c.name,"canceled")',
't?.onToolResult?.(f,c.name,"done")',
't?.onToolError?.(g,c.name)',
't?.onToolError?.(p,c.name)'
].join(';')
await writeFile(join(distribution, 'index.js'), sourceBundle, 'utf8')
return {
@@ -138,6 +149,12 @@ describe('ContinueHostAdapter', () => {
expect(bundle).toContain(
'useResponsesApi:e.useResponsesApi'
)
expect(bundle).toContain('"-NoProfile"')
expect(bundle).toContain('[Console]::OutputEncoding')
expect(bundle).toContain('goodbuddyEvents:[]')
expect(bundle).toContain('goodbuddyEvents:ce')
expect(bundle).toContain('type:"text",delta:u')
expect(bundle).toContain('onToolStart?.(c.name,c.arguments,c.id)')
expect(bundle).toContain(
'function ZZo(e){let t=[];if(e.allow)'
)
@@ -394,7 +411,7 @@ describe('ContinueHostAdapter', () => {
cacheWriteTokens: 0
}
})
expect(launch?.entryPath).toContain('host-v4')
expect(launch?.entryPath).toContain('host-v6')
expect(launch?.args).toEqual([
'--config',
expect.stringContaining('model-config-'),
@@ -870,6 +887,7 @@ describe('ContinueHostAdapter', () => {
const distribution = await createDistribution()
let launchArgs: string[] = []
const permissionBodies: unknown[] = []
const streamEvents: unknown[] = []
const launchHost: ContinueHostLauncher = (
_entryPath,
args
@@ -915,7 +933,16 @@ describe('ContinueHostAdapter', () => {
toolName: 'Bash',
toolArgs: { command: 'npm test' },
requestId: 'permission-1'
}
},
goodbuddyEvents: [
{ type: 'text', delta: '先检查命令。' },
{
type: 'tool',
callId: 'call-1',
name: 'Bash',
state: 'running'
}
]
})
}
return Response.json({
@@ -940,7 +967,16 @@ describe('ContinueHostAdapter', () => {
},
isProcessing: false,
messageQueueLength: 0,
pendingPermission: null
pendingPermission: null,
goodbuddyEvents: [
{
type: 'tool',
callId: 'call-1',
name: 'Bash',
state: 'completed'
},
{ type: 'text', delta: 'TOOLS_OK' }
]
})
}
return Response.json({})
@@ -959,9 +995,19 @@ describe('ContinueHostAdapter', () => {
const authorize = vi.fn(async () => 'once' as const)
await expect(
adapter.run('hello', new AbortController().signal, authorize)
adapter.run(
'hello',
new AbortController().signal,
authorize,
{
onEvent: (event) => {
streamEvents.push(event)
}
}
)
).resolves.toEqual({
text: 'TOOLS_OK',
streamedText: true,
tools: [
{
callId: 'call-1',
@@ -970,6 +1016,26 @@ describe('ContinueHostAdapter', () => {
}
]
})
expect(streamEvents).toEqual([
{ type: 'text', delta: '先检查命令。' },
{
type: 'tool',
tool: {
callId: 'call-1',
name: 'Bash',
state: 'running'
}
},
{
type: 'tool',
tool: {
callId: 'call-1',
name: 'Bash',
state: 'completed'
}
},
{ type: 'text', delta: 'TOOLS_OK' }
])
expect(launchArgs).not.toContain('--readonly')
expect(authorize).toHaveBeenCalledWith(
expect.objectContaining({ toolName: 'Bash' })
+150 -6
View File
@@ -45,6 +45,7 @@ const maximumBundleBytes = 32 * 1024 * 1024
const maximumStateBytes = 8 * 1024 * 1024
const maximumConfigBytes = 1024 * 1024
const maximumConfiguredMcpServers = 100
const maximumStreamEvents = 5_000
const knowledgeMcpName = 'goodbuddy-knowledge'
export const continueConfigurationRequiredMessage =
'Continue 尚未配置模型连接,请在设置中选择 GoodBuddy 模型连接或指定 Continue 配置文件'
@@ -74,6 +75,24 @@ const sessionUsageSchema = z.object({
.optional()
})
const continueHostStreamEventSchema = z.discriminatedUnion('type', [
z
.object({
type: z.literal('text'),
delta: z.string().min(1).max(100_000)
})
.strict(),
z
.object({
type: z.literal('tool'),
callId: z.string().min(1).max(256),
name: z.string().min(1).max(200),
state: z.enum(['running', 'completed', 'failed']),
error: z.string().max(1_000).optional()
})
.strict()
])
const stateSchema = z.object({
session: z.object({
history: z.array(z.unknown()).max(5_000),
@@ -88,7 +107,11 @@ const stateSchema = z.object({
requestId: z.string().min(1).max(256),
toolCallPreview: z.array(z.unknown()).max(100).optional()
})
.nullable()
.nullable(),
goodbuddyEvents: z
.array(continueHostStreamEventSchema)
.max(maximumStreamEvents)
.optional()
})
type ContinueHostState = z.infer<typeof stateSchema>
@@ -124,10 +147,15 @@ export type ContinueHostTool = {
export type ContinueHostRunResult = {
text: string
streamedText?: true
usage?: ContinueHostUsage
tools?: ContinueHostTool[]
}
export type ContinueHostStreamEvent =
| { type: 'text'; delta: string }
| { type: 'tool'; tool: ContinueHostTool }
export class ContinueHostRunError extends Error {
constructor(
message: string,
@@ -158,6 +186,7 @@ export type ContinueHostRunOptions = {
endpoint: string
token: string
}
onEvent?: (event: ContinueHostStreamEvent) => void | Promise<void>
}
type KnowledgeCapability = NonNullable<
@@ -434,7 +463,7 @@ function extractContinueTools(
: 'failed'
const error =
normalizedState === 'failed'
? safeToolErrorDetail(state.output)
? normalizeContinueToolError(state.output)
: undefined
tools.set(callId, {
callId,
@@ -447,6 +476,28 @@ function extractContinueTools(
return [...tools.values()]
}
function mergeContinueTools(
current: ContinueHostTool[],
updates: ContinueHostTool[]
): ContinueHostTool[] {
const tools = new Map(current.map((tool) => [tool.callId, tool]))
for (const tool of updates) {
tools.set(tool.callId, tool)
}
return [...tools.values()]
}
function normalizeContinueToolError(value: unknown): string | undefined {
const detail = safeToolErrorDetail(value)
if (!detail) {
return undefined
}
const replacementCharacters = detail.match(/\uFFFD/gu)?.length ?? 0
return replacementCharacters >= 3
? 'PowerShell 输出编码异常,原始错误无法安全显示;请重试该命令'
: detail
}
function subtractTokenCount(completed: number, initial: number): number {
return Math.max(0, completed - initial)
}
@@ -538,6 +589,25 @@ export class ContinueHostAdapter {
'shouldUseResponsesEndpoint(t){return this.config.useResponsesApi===!1?!1:this.apiBase==="https://api.openai.com/v1/"&&A0e(t)}'
const modelConfigurationMarker =
'function uAe(e,t){let n={provider:e.provider,model:e.model,apiKey:e.apiKey,apiBase:e.apiBase,requestOptions:e.requestOptions,env:e.env};return CGn(n)??null}'
const windowsShellMarker =
'function Csa(e){return process.platform==="win32"?{shell:"powershell.exe",args:["-NoLogo","-ExecutionPolicy","Bypass","-Command",e]}'
const streamCallbacksMarker =
'a={onContent:u=>{},onContentComplete:u=>{},onToolStart:(u,l)=>{},onToolResult:(u,l,c)=>{},onToolError:(u,l)=>{},onToolPermissionRequest:'
const serverStateMarker = 'pendingPermission:null},B='
const serverStateEndpointMarker =
'j.get("/state",(we,Te)=>{M.lastActivity=Date.now(),B();let ue=e7e(M.session,M.isProcessing,rS.getQueueLength(),M.pendingPermission);Te.json(ue)})'
const preprocessToolStartMarker =
'n?.onToolStart?.(i.name,i.arguments);'
const preprocessToolErrorMarker =
'n?.onToolError?.(l,i.name)'
const executeToolStartMarker =
't?.onToolStart?.(c.name,c.arguments);'
const cancelledToolResultMarker =
't?.onToolResult?.(String(y.content),c.name,"canceled")'
const completedToolResultMarker =
't?.onToolResult?.(f,c.name,"done")'
const failedToolResultMarker = 't?.onToolError?.(g,c.name)'
const permissionToolErrorMarker = 't?.onToolError?.(p,c.name)'
let patched = replaceExactly(
sourceBundle,
serveInitializationMarker,
@@ -583,11 +653,66 @@ export class ContinueHostAdapter {
modelConfigurationMarker,
'function uAe(e,t){let n={provider:e.provider,model:e.model,apiKey:e.apiKey,apiBase:e.apiBase,requestOptions:e.requestOptions,env:e.env,useResponsesApi:e.useResponsesApi};return CGn(n)??null}'
)
patched = replaceExactly(
patched,
windowsShellMarker,
'function Csa(e){return process.platform==="win32"?{shell:"powershell.exe",args:["-NoLogo","-NoProfile","-ExecutionPolicy","Bypass","-Command",\'[Console]::InputEncoding=[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false);$OutputEncoding=[Console]::OutputEncoding;\'+e]}'
)
patched = replaceExactly(
patched,
streamCallbacksMarker,
'a={onContent:u=>{u&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"text",delta:u})},onContentComplete:u=>{},onToolStart:(u,l,c)=>{c&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"tool",callId:c,name:u,state:"running"})},onToolResult:(u,l,c,d)=>{d&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"tool",callId:d,name:l,state:c==="done"?"completed":"failed"})},onToolError:(u,l,c)=>{c&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"tool",callId:c,name:l??"unknown",state:"failed",error:String(u).slice(0,1e3)})},onToolPermissionRequest:'
)
patched = replaceExactly(
patched,
serverStateMarker,
'pendingPermission:null,goodbuddyEvents:[]},B='
)
patched = replaceExactly(
patched,
serverStateEndpointMarker,
'j.get("/state",(we,Te)=>{M.lastActivity=Date.now(),B();let ue=e7e(M.session,M.isProcessing,rS.getQueueLength(),M.pendingPermission),ce=M.goodbuddyEvents.splice(0);Te.json({...ue,goodbuddyEvents:ce})})'
)
patched = replaceExactly(
patched,
preprocessToolStartMarker,
'n?.onToolStart?.(i.name,i.arguments,i.id);'
)
patched = replaceExactly(
patched,
preprocessToolErrorMarker,
'n?.onToolError?.(l,i.name,i.id)'
)
patched = replaceExactly(
patched,
executeToolStartMarker,
't?.onToolStart?.(c.name,c.arguments,c.id);'
)
patched = replaceExactly(
patched,
cancelledToolResultMarker,
't?.onToolResult?.(String(y.content),c.name,"canceled",c.id)'
)
patched = replaceExactly(
patched,
completedToolResultMarker,
't?.onToolResult?.(f,c.name,"done",c.id)'
)
patched = replaceExactly(
patched,
failedToolResultMarker,
't?.onToolError?.(g,c.name,c.id)'
)
patched = replaceExactly(
patched,
permissionToolErrorMarker,
't?.onToolError?.(p,c.name,c.id)'
)
const patchedHash = hashContents(patched)
const digest = sourceHash.slice(0, 16)
const targetRoot = join(
this.options.cacheRoot,
`host-v4-${supportedVersion}-${digest}`
`host-v6-${supportedVersion}-${digest}`
)
const targetDist = join(targetRoot, 'dist')
const targetBundle = join(targetDist, 'index.js')
@@ -942,6 +1067,7 @@ export class ContinueHostAdapter {
signal.addEventListener('abort', abort, { once: true })
let observedTools: ContinueHostTool[] = []
let streamedText = false
try {
const initialState = await this.waitForStartup(
child,
@@ -972,10 +1098,27 @@ export class ContinueHostAdapter {
const state = stateSchema.parse(
await this.request(origin, token, '/state', { signal })
)
observedTools = extractContinueTools(
state.session.history,
startIndex
observedTools = mergeContinueTools(
observedTools,
extractContinueTools(state.session.history, startIndex)
)
for (const event of state.goodbuddyEvents ?? []) {
if (event.type === 'text') {
streamedText = true
await runOptions.onEvent?.(event)
continue
}
const tool: ContinueHostTool = {
callId: event.callId,
name: event.name,
state: event.state,
...(event.error
? { error: normalizeContinueToolError(event.error) }
: {})
}
observedTools = mergeContinueTools(observedTools, [tool])
await runOptions.onEvent?.({ type: 'tool', tool })
}
const pending = state.pendingPermission
if (pending && !handledPermissionIds.has(pending.requestId)) {
if (handledPermissionIds.size >= 100) {
@@ -1053,6 +1196,7 @@ export class ContinueHostAdapter {
)
return {
text,
...(streamedText ? { streamedText: true as const } : {}),
...(usage ? { usage } : {}),
...(observedTools.length > 0
? { tools: observedTools }
+90 -10
View File
@@ -101,7 +101,10 @@ describe('ContinueAgentRuntime', () => {
expect(mocks.runHost).toHaveBeenCalledWith(
'test',
expect.any(AbortSignal),
expect.any(Function)
expect.any(Function),
expect.objectContaining({
onEvent: expect.any(Function)
})
)
expect(events).toContainEqual({
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
@@ -189,7 +192,8 @@ describe('ContinueAgentRuntime', () => {
knowledgeCapability: {
endpoint: 'http://127.0.0.1:4567/mcp',
token: 'main-only-token'
}
},
onEvent: expect.any(Function)
}
)
const authorize = mocks.runHost.mock.calls[0]?.[2]
@@ -403,6 +407,74 @@ describe('ContinueAgentRuntime', () => {
])
})
it('forwards streamed text and tool events in host order', async () => {
mocks.runHost.mockImplementation(
async (
_prompt,
_signal,
_authorize,
options
) => {
await options?.onEvent?.({
type: 'text',
delta: '先分析'
})
await options?.onEvent?.({
type: 'tool',
tool: {
callId: 'call-1',
name: 'Read',
state: 'running'
}
})
await options?.onEvent?.({
type: 'tool',
tool: {
callId: 'call-1',
name: 'Read',
state: 'completed'
}
})
await options?.onEvent?.({
type: 'text',
delta: '再回答'
})
return {
text: '再回答',
streamedText: true,
tools: [
{
callId: 'call-1',
name: 'Read',
state: 'completed'
}
]
}
}
)
const events = await collectEvents(createRuntime(), 'execute')
expect(
events.filter(
(event) => event.type === 'text' || event.type === 'tool'
)
).toEqual([
expect.objectContaining({ type: 'text', delta: '先分析' }),
expect.objectContaining({
type: 'tool',
callId: 'call-1',
state: 'running'
}),
expect.objectContaining({
type: 'tool',
callId: 'call-1',
state: 'completed'
}),
expect.objectContaining({ type: 'text', delta: '再回答' })
])
})
it('emits terminal tool audits before a failed Continue run', async () => {
mocks.runHost.mockRejectedValue(
new ContinueHostRunError('Continue failed', {
@@ -441,7 +513,7 @@ describe('ContinueAgentRuntime', () => {
await expect(stream.next()).rejects.toThrow('Continue failed')
})
it('returns a failed Continue tool detail through AgentRuntime', async () => {
it('keeps a completed Continue response when an earlier tool attempt failed', async () => {
mocks.runHost.mockResolvedValue({
text: 'Continue response',
tools: [
@@ -466,17 +538,25 @@ describe('ContinueAgentRuntime', () => {
await expect(stream.next()).resolves.toMatchObject({
value: { type: 'status' }
})
await expect(stream.next()).resolves.toMatchObject({
value: {
const events: RuntimeEvent[] = []
for await (const event of stream) {
events.push(event)
}
expect(events).toContainEqual(
expect.objectContaining({
type: 'tool',
callId: 'call-1',
state: 'failed',
state: 'recoverable',
error: 'PowerShell EmptyPipeElement'
}
})
await expect(stream.next()).rejects.toThrow(
'PowerShell EmptyPipeElement'
})
)
expect(events).toContainEqual(
expect.objectContaining({
type: 'text',
delta: 'Continue response'
})
)
expect(events.at(-1)).toMatchObject({ type: 'done' })
})
it('fails a run that returns a nonterminal tool state', async () => {
+109 -22
View File
@@ -20,6 +20,7 @@ import {
type ContinueHostAdapterOptions,
type ContinueHostLauncher,
type ContinueHostRunResult,
type ContinueHostStreamEvent,
type ContinueHostTool
} from './continue-host-adapter'
@@ -56,7 +57,8 @@ function continueToolFailureMessage(tool: ContinueHostTool): string {
function toContinueToolEvent(
requestId: string,
tool: ContinueHostTool,
terminalize: boolean
terminalize: boolean,
recoverFailure = false
): Extract<AgentEvent, { type: 'tool' }> {
return {
requestId,
@@ -64,7 +66,9 @@ function toContinueToolEvent(
callId: tool.callId,
name: tool.name,
state:
terminalize && tool.state !== 'completed'
recoverFailure && tool.state === 'failed'
? 'recoverable'
: terminalize && tool.state !== 'completed'
? 'failed'
: tool.state,
summary: `Continue 工具:${tool.name}`,
@@ -283,6 +287,7 @@ export class ContinueAgentRuntime implements AgentRuntime {
}
: undefined
let result: ContinueHostRunResult
const emittedTools = new Map<string, ContinueHostTool>()
try {
const host = this.getHostAdapter(
binaryPath,
@@ -299,17 +304,72 @@ export class ContinueAgentRuntime implements AgentRuntime {
approval.toolName === 'knowledge_search')
? 'once' as const
: 'deny' as const
result = knowledgeCapability
? await host.run(
conversationContext,
signal,
authorize,
{
workMode: request.workMode,
knowledgeCapability
const queuedEvents: ContinueHostStreamEvent[] = []
let wakeStream: (() => void) | undefined
let streamFinished = false
let streamResult: ContinueHostRunResult | undefined
let streamError: unknown
const onEvent = (event: ContinueHostStreamEvent): void => {
queuedEvents.push(event)
wakeStream?.()
wakeStream = undefined
}
const hostRun = host
.run(
conversationContext,
signal,
authorize,
{
workMode: request.workMode,
...(knowledgeCapability ? { knowledgeCapability } : {}),
onEvent
}
)
.then(
(value) => {
streamResult = value
},
(error: unknown) => {
streamError = error
}
)
.finally(() => {
streamFinished = true
wakeStream?.()
wakeStream = undefined
})
while (!streamFinished || queuedEvents.length > 0) {
if (queuedEvents.length === 0) {
await new Promise<void>((resolve) => {
wakeStream = resolve
})
continue
}
const event = queuedEvents.shift()!
if (event.type === 'tool') {
emittedTools.set(event.tool.callId, event.tool)
}
yield event.type === 'text'
? {
requestId: request.requestId,
type: 'text',
delta: event.delta
}
)
: await host.run(conversationContext, signal, authorize)
: toContinueToolEvent(
request.requestId,
event.tool,
false
)
}
await hostRun
if (streamError) {
throw streamError
}
if (!streamResult) {
throw new Error('Continue 宿主未返回运行结果')
}
result = streamResult
} catch (error) {
if (error instanceof ContinueHostRunError) {
for (const tool of error.tools) {
@@ -323,23 +383,50 @@ export class ContinueAgentRuntime implements AgentRuntime {
}
const tools = result.tools ?? []
const unsuccessfulTool = tools.find(
(tool) => tool.state !== 'completed'
const incompleteTool = tools.find(
(tool) => tool.state === 'pending' || tool.state === 'running'
)
if (unsuccessfulTool) {
if (incompleteTool) {
for (const tool of tools) {
yield toContinueToolEvent(request.requestId, tool, true)
const terminalEvent = toContinueToolEvent(
request.requestId,
tool,
true
)
const previous = emittedTools.get(tool.callId)
if (
!previous ||
previous.state !== terminalEvent.state ||
previous.error !== terminalEvent.error
) {
yield terminalEvent
}
}
throw new Error(continueToolFailureMessage(unsuccessfulTool))
throw new Error(continueToolFailureMessage(incompleteTool))
}
for (const tool of tools) {
yield toContinueToolEvent(request.requestId, tool, false)
const finalEvent = toContinueToolEvent(
request.requestId,
tool,
false,
true
)
const previous = emittedTools.get(tool.callId)
if (
!previous ||
previous.state !== finalEvent.state ||
previous.error !== finalEvent.error
) {
yield finalEvent
}
}
yield {
requestId: request.requestId,
type: 'text',
delta: result.text
if (!result.streamedText) {
yield {
requestId: request.requestId,
type: 'text',
delta: result.text
}
}
if (result.usage) {
const usage = result.usage
+119
View File
@@ -153,6 +153,14 @@ function runClient(events: Record<string, unknown>[]) {
data: true,
error: undefined
})
const questionReply = vi.fn().mockResolvedValue({
data: true,
error: undefined
})
const questionReject = vi.fn().mockResolvedValue({
data: true,
error: undefined
})
const client = {
session: {
list: vi.fn().mockResolvedValue({ data: [], error: undefined }),
@@ -192,6 +200,10 @@ function runClient(events: Record<string, unknown>[]) {
permission: {
reply: permissionReply
},
question: {
reply: questionReply,
reject: questionReject
},
mcp: {
add: vi
.fn()
@@ -218,6 +230,8 @@ function runClient(events: Record<string, unknown>[]) {
client,
callOrder,
permissionReply,
questionReply,
questionReject,
session: client.session,
event: client.event,
tool: client.tool
@@ -978,6 +992,84 @@ describe('OpenCodeRuntime embedded launcher', () => {
})
describe('OpenCodeRuntime embedded permission mediation', () => {
it('parses OpenCode questions and sends the selected answers back', async () => {
const setup = runClient([
{
id: 'question-event',
type: 'question.asked',
properties: {
id: 'question-1',
sessionID: 'session-1',
questions: [
{
header: '实现方式',
question: '请选择实现方式',
options: [
{
label: '直接修改',
description: '立即更新现有实现'
},
{
label: '先写测试',
description: '先增加回归测试'
}
],
multiple: false,
custom: true
}
],
tool: {
messageID: 'message-1',
callID: 'call-question-1'
}
}
},
{
id: 'idle',
type: 'session.idle',
properties: { sessionID: 'session-1' }
}
])
const runtime = embeddedRuntime(setup.client)
const stream = runtime.run(
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-1',
prompt: 'test',
workMode: 'execute'
},
new AbortController().signal
)
await expect(stream.next()).resolves.toMatchObject({
value: { type: 'status' }
})
await expect(stream.next()).resolves.toMatchObject({
value: {
type: 'question',
questionId: 'question-1',
questions: [
{
header: '实现方式',
question: '请选择实现方式',
multiple: false,
custom: true
}
]
}
})
await runtime.respondToQuestion('question-1', [['先写测试']])
expect(setup.questionReply).toHaveBeenCalledWith({
requestID: 'question-1',
directory: process.cwd(),
answers: [['先写测试']]
})
await expect(stream.next()).resolves.toMatchObject({
value: { type: 'done' }
})
await runtime.dispose()
})
it('adds only the request-scoped knowledge MCP tool for Ask and disconnects it', async () => {
const setup = runClient([
{
@@ -1420,6 +1512,33 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
delta: 'approved output'
})
)
expect(
events.filter(
(event) =>
event.type === 'reasoning' ||
event.type === 'text' ||
event.type === 'tool'
)
).toEqual([
expect.objectContaining({
type: 'tool',
callId: 'call-1',
state: 'pending'
}),
expect.objectContaining({
type: 'tool',
callId: 'call-1',
state: 'completed'
}),
expect.objectContaining({
type: 'reasoning',
delta: 'reasoning output'
}),
expect.objectContaining({
type: 'text',
delta: 'approved output'
})
])
expect(events.at(-1)).toMatchObject({ type: 'done' })
await runtime.dispose()
})
+160 -2
View File
@@ -3,12 +3,16 @@ import {
type AssistantMessage,
type OpencodeClient,
type PermissionRequest,
type PermissionRuleset
type PermissionRuleset,
type QuestionRequest
} from '@opencode-ai/sdk/v2'
import spawn from 'cross-spawn'
import { createHash, randomBytes } from 'node:crypto'
import { resolve } from 'node:path'
import type { AgentRuntimeStatus } from '../../shared/contracts'
import type {
AgentQuestionAnswer,
AgentRuntimeStatus
} from '../../shared/contracts'
import { createAnthropicApiBaseUrl } from './anthropic-endpoint'
import { createOpenAIApiBaseUrl } from './openai-endpoint'
import type {
@@ -42,6 +46,9 @@ const MAX_PERMISSION_PATTERN_LENGTH = 1_024
const MAX_PERMISSION_PATTERNS_BYTES = 8 * 1_024
const MAX_PERMISSION_METADATA_BYTES = 8 * 1_024
const MAX_TOOL_CALLS_PER_RUN = 100
const MAX_QUESTION_REQUEST_BYTES = 32 * 1_024
const MAX_QUESTIONS_PER_REQUEST = 4
const MAX_QUESTION_OPTIONS = 20
const EMBEDDED_SERVER_USERNAME = 'goodbuddy'
type SpawnedProcess = ReturnType<typeof spawn>
@@ -230,6 +237,69 @@ function parsePermissionRequest(
return properties as PermissionRequest
}
function parseQuestionRequest(
properties: unknown,
sessionId: string
): QuestionRequest | undefined {
if (!isRecord(properties) || properties.sessionID !== sessionId) {
return undefined
}
const { id, questions, tool } = properties
if (
typeof id !== 'string' ||
id.length === 0 ||
id.length > MAX_PERMISSION_NAME_LENGTH ||
!Array.isArray(questions) ||
questions.length === 0 ||
questions.length > MAX_QUESTIONS_PER_REQUEST ||
!questions.every(
(question) =>
isRecord(question) &&
typeof question.question === 'string' &&
question.question.trim().length > 0 &&
question.question.length <= 2_000 &&
typeof question.header === 'string' &&
question.header.trim().length > 0 &&
question.header.length <= 120 &&
Array.isArray(question.options) &&
question.options.length <= MAX_QUESTION_OPTIONS &&
question.options.every(
(option) =>
isRecord(option) &&
typeof option.label === 'string' &&
option.label.trim().length > 0 &&
option.label.length <= 200 &&
typeof option.description === 'string' &&
option.description.length <= 1_000
) &&
(question.multiple === undefined ||
typeof question.multiple === 'boolean') &&
(question.custom === undefined ||
typeof question.custom === 'boolean')
) ||
(tool !== undefined &&
(!isRecord(tool) ||
typeof tool.messageID !== 'string' ||
tool.messageID.length === 0 ||
tool.messageID.length > 256 ||
typeof tool.callID !== 'string' ||
tool.callID.length === 0 ||
tool.callID.length > 256))
) {
throw new Error('OpenCode 提问请求格式无效')
}
let serialized: string
try {
serialized = JSON.stringify(properties)
} catch {
throw new Error('OpenCode 提问请求无法序列化')
}
if (!byteLengthWithin(serialized, MAX_QUESTION_REQUEST_BYTES)) {
throw new Error('OpenCode 提问请求超过安全限制')
}
return properties as QuestionRequest
}
function isSafeTokenCount(value: number): boolean {
return Number.isSafeInteger(value) && value >= 0
}
@@ -355,6 +425,14 @@ export class OpenCodeRuntime implements AgentRuntime {
string,
Promise<string>
>()
private readonly pendingQuestions = new Map<
string,
{
client: OpencodeClient
directory: string
questionCount: number
}
>()
private embeddedRunTail: Promise<void> = Promise.resolve()
private readonly dependencies: OpenCodeRuntimeDependencies
@@ -904,6 +982,7 @@ export class OpenCodeRuntime implements AgentRuntime {
}
>()
const reasoningPartIds = new Set<string>()
const reportedQuestionIds = new Set<string>()
try {
const promptText =
session.created && request.history?.length
@@ -1027,6 +1106,50 @@ export class OpenCodeRuntime implements AgentRuntime {
}
}
if (
event.type === 'question.asked' &&
event.properties.sessionID === sessionId
) {
const questionRequest = parseQuestionRequest(
event.properties,
sessionId
)
if (
questionRequest &&
!reportedQuestionIds.has(questionRequest.id)
) {
reportedQuestionIds.add(questionRequest.id)
this.pendingQuestions.set(questionRequest.id, {
client,
directory,
questionCount: questionRequest.questions.length
})
yield {
requestId: request.requestId,
type: 'question',
questionId: questionRequest.id,
questions: questionRequest.questions.map((question) => ({
header: question.header,
question: question.question,
options: question.options.map((option) => ({
label: option.label,
description: option.description
})),
multiple: question.multiple ?? false,
custom: question.custom ?? true
}))
}
}
}
if (
(event.type === 'question.replied' ||
event.type === 'question.rejected') &&
event.properties.sessionID === sessionId
) {
this.pendingQuestions.delete(event.properties.requestID)
}
if (
this.usesEmbeddedPermissionMediation() &&
event.type === 'permission.asked'
@@ -1193,6 +1316,9 @@ export class OpenCodeRuntime implements AgentRuntime {
throw error
} finally {
signal.removeEventListener('abort', abortSession)
for (const questionId of reportedQuestionIds) {
this.pendingQuestions.delete(questionId)
}
}
} finally {
if (knowledgeMcpName) {
@@ -1203,7 +1329,39 @@ export class OpenCodeRuntime implements AgentRuntime {
}
}
async respondToQuestion(
questionId: string,
answers?: AgentQuestionAnswer[]
): Promise<void> {
const pending = this.pendingQuestions.get(questionId)
if (!pending) {
throw new Error('OpenCode 提问已失效或不存在')
}
const response = answers
? answers.length === pending.questionCount
? await pending.client.question.reply({
requestID: questionId,
directory: pending.directory,
answers
})
: undefined
: await pending.client.question.reject({
requestID: questionId,
directory: pending.directory
})
if (!response) {
throw new Error('OpenCode 提问回答数量不匹配')
}
if (response.error || response.data !== true) {
throw new Error(
answers ? 'OpenCode 提交回答失败' : 'OpenCode 取消提问失败'
)
}
this.pendingQuestions.delete(questionId)
}
async dispose(): Promise<void> {
this.pendingQuestions.clear()
const startingChild = this.startingChild
this.startingChild = undefined
if (startingChild) {
+15
View File
@@ -1,4 +1,5 @@
import type {
AgentQuestionAnswer,
AgentRequest,
AgentRuntimeStatus
} from '../../shared/contracts'
@@ -167,6 +168,20 @@ export class AgentRuntimeController implements AgentRuntime {
await this.current.runtime.releaseConversation?.(conversationId)
}
async respondToQuestion(
questionId: string,
answers?: AgentQuestionAnswer[]
): Promise<void> {
if (this.closing) {
throw new Error('Agent Runtime 正在关闭')
}
const runtime = this.current.runtime
if (!runtime.respondToQuestion) {
throw new Error('当前 Runtime 不支持回答交互式问题')
}
await runtime.respondToQuestion(questionId, answers)
}
private retire(slot: RuntimeSlot): Promise<void> {
slot.retiring = true
if (!slot.disposal) {
+5
View File
@@ -1,6 +1,7 @@
import type {
ApprovalDecision,
AgentEvent,
AgentQuestionAnswer,
AgentRequest,
AgentRuntimeStatus
} from '../../shared/contracts'
@@ -57,6 +58,10 @@ export interface AgentRuntime {
signal: AbortSignal,
authorize?: RuntimeAuthorizer
): AsyncGenerator<RuntimeEvent, void, void>
respondToQuestion?(
questionId: string,
answers?: AgentQuestionAnswer[]
): Promise<void>
releaseConversation?(conversationId: string): Promise<void>
dispose(): Promise<void>
}
@@ -71,6 +71,44 @@ describe('SelectedRuntimeManager', () => {
await manager.dispose()
})
it('isolates cached runtimes by effective project workspace', async () => {
const first = runtime()
const second = runtime()
const create = vi
.fn()
.mockResolvedValueOnce(first.value)
.mockResolvedValueOnce(second.value)
const manager = new SelectedRuntimeManager(create)
const selection = { provider: 'opencode' as const }
const projectOne = await manager.getRuntime(
selection,
'C:\\Projects\\One'
)
const projectOneAgain = await manager.getRuntime(
selection,
'C:\\Projects\\One'
)
const projectTwo = await manager.getRuntime(
selection,
'C:\\Projects\\Two'
)
expect(projectOneAgain).toBe(projectOne)
expect(projectTwo).not.toBe(projectOne)
expect(create).toHaveBeenNthCalledWith(
1,
selection,
'C:\\Projects\\One'
)
expect(create).toHaveBeenNthCalledWith(
2,
selection,
'C:\\Projects\\Two'
)
await manager.dispose()
})
it('retires cached runtimes when settings change', async () => {
const first = runtime()
const second = runtime()
+21 -10
View File
@@ -7,7 +7,10 @@ import type { AgentRuntime } from './runtime'
import { AgentRuntimeController } from './runtime-controller'
export type SelectedRuntimeResolver = {
getRuntime(selection: AgentRuntimeSelection): Promise<AgentRuntime>
getRuntime(
selection: AgentRuntimeSelection,
workspacePath?: string
): Promise<AgentRuntime>
getStatus(
selection: AgentRuntimeSelection
): Promise<AgentRuntimeStatus>
@@ -15,6 +18,7 @@ export type SelectedRuntimeResolver = {
selection: AgentRuntimeSelection
): Promise<AgentRuntimeStatus>
releaseConversation(conversationId: string): Promise<void>
reset?(): Promise<void>
}
export class SelectedRuntimeManager implements SelectedRuntimeResolver {
@@ -28,28 +32,35 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
constructor(
private readonly createRuntime: (
selection: AgentRuntimeSelection
selection: AgentRuntimeSelection,
workspacePath?: string
) => Promise<AgentRuntime>
) {}
async getRuntime(
selection: AgentRuntimeSelection
selection: AgentRuntimeSelection,
workspacePath?: string
): Promise<AgentRuntime> {
if (this.disposed) {
throw new Error('Agent Runtime 正在关闭')
}
const key = agentRuntimeSelectionKey(selection)
const key = JSON.stringify([
agentRuntimeSelectionKey(selection),
workspacePath ?? ''
])
const existing = this.entries.get(key)
if (existing) {
return existing
}
const operation = this.createRuntime(selection).then(async (runtime) => {
if (this.disposed || this.entries.get(key) !== operation) {
await runtime.dispose()
throw new Error('Runtime 设置已更改,请重新选择')
const operation = this.createRuntime(selection, workspacePath).then(
async (runtime) => {
if (this.disposed || this.entries.get(key) !== operation) {
await runtime.dispose()
throw new Error('Runtime 设置已更改,请重新选择')
}
return new AgentRuntimeController(runtime)
}
return new AgentRuntimeController(runtime)
})
)
this.entries.set(key, operation)
try {
return await operation
@@ -234,6 +234,97 @@ describe('AssistantDatabase', () => {
database.close()
})
it('safely deletes a confirmed project and its scoped data', async () => {
const database = await createDatabase()
const project = database.createProject({
name: '待删除项目',
description: '删除测试',
rootPath: 'C:\\Delete',
defaultWorkMode: 'execute'
})
const conversationId = '00000000-0000-4000-8000-000000000111'
const taskId = '00000000-0000-4000-8000-000000000211'
database.replaceConversations([
{
id: conversationId,
projectId: project.id,
title: '项目对话',
updatedAt: Date.now(),
messages: []
}
])
database.createTask({
id: taskId,
projectId: project.id,
conversationId,
title: '项目任务',
instructions: '执行任务',
workMode: 'execute'
})
database.createTextArtifact({
projectId: project.id,
taskId,
title: '项目成果',
content: '内容'
})
database.createMemory({
scope: 'project',
scopeId: project.id,
type: 'fact',
content: '项目记忆'
})
database.createSchedule({
projectId: project.id,
title: '项目计划',
prompt: '执行计划',
workMode: 'ask',
recurrence: 'daily',
nextRunAt: '2026-08-08T00:00:00.000Z'
})
expect(() =>
database.deleteProject(project.id, project.name)
).toThrow('项目仍有进行中的任务')
database.updateTaskStatus(taskId, 'completed')
expect(() =>
database.deleteProject(project.id, '错误名称')
).toThrow('项目名称确认不匹配')
database.deleteProject(project.id, project.name)
expect(
database.listProjects(true).some((item) => item.id === project.id)
).toBe(false)
expect(
database.listConversations().some(
(conversation) => conversation.projectId === project.id
)
).toBe(false)
expect(
database.listTasks().some((task) => task.projectId === project.id)
).toBe(false)
expect(database.listArtifacts(project.id)).toEqual([])
expect(database.listSchedules(project.id)).toEqual([])
expect(
database
.listMemories(project.id)
.some((memory) => memory.scopeId === project.id)
).toBe(false)
expect(database.listProjects()).toHaveLength(1)
database.close()
})
it('does not delete the final active project', async () => {
const database = await createDatabase()
const project = database.listProjects()[0]!
expect(() =>
database.deleteProject(project.id, project.name)
).toThrow('至少需要保留一个可用项目')
expect(database.listProjects()).toHaveLength(1)
database.close()
})
it('creates, updates, and soft-deletes expert roles', async () => {
const database = await createDatabase()
const expert = database.createExpert({
@@ -613,6 +704,29 @@ describe('AssistantDatabase', () => {
id: '00000000-0000-4000-8000-000000000213',
role: 'assistant',
content: '处理中',
reasoning: '先分析发布范围',
blocks: [
{
id: '00000000-0000-4000-8000-000000000217',
type: 'reasoning',
content: '先分析发布范围'
},
{
id: '00000000-0000-4000-8000-000000000218',
type: 'tool',
tool: {
callId: 'call-1',
name: 'read',
state: 'running',
summary: 'OpenCode 工具:read'
}
},
{
id: '00000000-0000-4000-8000-000000000219',
type: 'text',
content: '处理中'
}
],
createdAt: 1_775_000_001_000,
state: 'streaming',
artifactIds: [
@@ -665,6 +779,24 @@ describe('AssistantDatabase', () => {
role: 'assistant',
state: 'error',
status: expect.stringContaining('意外中断'),
reasoning: '先分析发布范围',
blocks: [
expect.objectContaining({
type: 'reasoning',
content: '先分析发布范围'
}),
expect.objectContaining({
type: 'tool',
tool: expect.objectContaining({
callId: 'call-1',
state: 'interrupted'
})
}),
expect.objectContaining({
type: 'text',
content: '处理中'
})
],
artifactIds: [
'00000000-0000-4000-8000-000000000216'
],
+121 -2
View File
@@ -89,6 +89,8 @@ type MessageRow = {
type MessageMetadata = {
createdAt?: number
status?: string
reasoning?: ConversationSnapshot['messages'][number]['reasoning']
blocks?: ConversationSnapshot['messages'][number]['blocks']
tools?: ConversationSnapshot['messages'][number]['tools']
sources?: string[]
sourceReferences?: ConversationSnapshot['messages'][number]['sourceReferences']
@@ -579,6 +581,20 @@ function interruptActiveTools(
)
}
function interruptActiveToolBlocks(
blocks: MessageMetadata['blocks']
): MessageMetadata['blocks'] {
return blocks?.map((block) =>
block.type === 'tool' &&
(block.tool.state === 'pending' || block.tool.state === 'running')
? {
...block,
tool: { ...block.tool, state: 'interrupted' as const }
}
: block
)
}
export class AssistantDatabase {
private database?: DatabaseSync
@@ -714,7 +730,13 @@ export class AssistantDatabase {
metadata.tools?.some(
(tool) =>
tool.state === 'pending' || tool.state === 'running'
)
) ||
metadata.blocks?.some(
(block) =>
block.type === 'tool' &&
(block.tool.state === 'pending' ||
block.tool.state === 'running')
)
)
if (message.state !== 'streaming' && !hasActiveTool) {
continue
@@ -727,7 +749,8 @@ export class AssistantDatabase {
message.state === 'streaming'
? interruptedMessageStatus
: metadata.status,
tools: interruptActiveTools(metadata.tools)
tools: interruptActiveTools(metadata.tools),
blocks: interruptActiveToolBlocks(metadata.blocks)
}),
message.id
)
@@ -859,6 +882,96 @@ export class AssistantDatabase {
}
}
deleteProject(projectId: string, confirmation: string): void {
const database = this.requireDatabase()
database.exec('BEGIN IMMEDIATE')
try {
const project = database
.prepare('SELECT name, status FROM projects WHERE id = ?')
.get(projectId) as
| { name: string; status: AssistantProject['status'] }
| undefined
if (!project) {
throw new Error('项目不存在')
}
if (confirmation !== project.name) {
throw new Error('项目名称确认不匹配')
}
const activeProjectCount = database
.prepare(
`SELECT COUNT(*) AS count FROM projects
WHERE status = 'active'`
)
.get() as { count: number }
if (project.status === 'active' && activeProjectCount.count <= 1) {
throw new Error('至少需要保留一个可用项目')
}
const activeTaskCount = database
.prepare(
`SELECT COUNT(*) AS count FROM tasks
WHERE project_id = ?
AND status IN ('queued', 'running', 'waiting_approval', 'paused')`
)
.get(projectId) as { count: number }
if (activeTaskCount.count > 0) {
throw new Error('项目仍有进行中的任务,请先停止任务')
}
database
.prepare(
`DELETE FROM notifications
WHERE task_id IN (
SELECT id FROM tasks WHERE project_id = ?
) OR schedule_id IN (
SELECT id FROM schedules WHERE project_id = ?
)`
)
.run(projectId, projectId)
database
.prepare(
`DELETE FROM delegation_outbox
WHERE task_id IN (
SELECT id FROM tasks WHERE project_id = ?
)`
)
.run(projectId)
database
.prepare(
`DELETE FROM memory_items
WHERE (scope = 'project' AND scope_id = ?)
OR (scope = 'conversation' AND scope_id IN (
SELECT id FROM conversations WHERE project_id = ?
))`
)
.run(projectId, projectId)
database
.prepare('DELETE FROM heartbeat_configs WHERE project_id = ?')
.run(projectId)
database
.prepare('DELETE FROM artifacts WHERE project_id = ?')
.run(projectId)
database
.prepare('DELETE FROM tasks WHERE project_id = ?')
.run(projectId)
database
.prepare('DELETE FROM conversations WHERE project_id = ?')
.run(projectId)
database
.prepare('DELETE FROM schedules WHERE project_id = ?')
.run(projectId)
const result = database
.prepare('DELETE FROM projects WHERE id = ?')
.run(projectId)
if (result.changes !== 1) {
throw new Error('项目不存在')
}
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
throw error
}
}
listConversations(): ConversationSnapshot[] {
const database = this.requireDatabase()
const conversations = database
@@ -897,6 +1010,10 @@ export class AssistantDatabase {
id: message.id,
role: message.role,
content: message.content,
reasoning: metadata.reasoning,
blocks: interrupted
? interruptActiveToolBlocks(metadata.blocks)
: metadata.blocks,
createdAt:
metadata.createdAt ?? Date.parse(message.created_at),
state: interrupted ? ('error' as const) : message.state,
@@ -1006,6 +1123,8 @@ export class AssistantDatabase {
JSON.stringify({
createdAt: message.createdAt,
status: message.status,
reasoning: message.reasoning,
blocks: message.blocks,
tools: message.tools,
sources: message.sources,
sourceReferences: message.sourceReferences,
@@ -62,7 +62,7 @@ describe('getWorkspaceChanges', () => {
expect(changes.patch).toContain('+after')
})
it('fails safely for a non-Git directory', async () => {
it('keeps file browsing available without reporting Git errors', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-changes-'))
temporaryDirectories.push(directory)
@@ -70,7 +70,7 @@ describe('getWorkspaceChanges', () => {
expect(changes.available).toBe(false)
expect(changes.files).toEqual([])
expect(changes.error).toBeTruthy()
expect(changes.error).toBeUndefined()
})
})
@@ -1,5 +1,6 @@
import spawn from 'cross-spawn'
import { basename, extname } from 'node:path'
import { basename, extname, join } from 'node:path'
import { stat } from 'node:fs/promises'
import type {
WorkspaceChangedFile,
WorkspaceChanges,
@@ -164,6 +165,15 @@ async function resolveWorkspacePath(
}
}
export async function resolveWorkspaceEntryPath(
rootPath: string,
inputPath: string,
expected: 'file' | 'directory'
): Promise<string> {
return (await resolveWorkspacePath(rootPath, inputPath, expected))
.canonicalPath
}
function parseChangedFiles(status: string): {
files: WorkspaceChangedFile[]
truncated: boolean
@@ -223,6 +233,19 @@ export async function getWorkspaceChanges(
error: '项目尚未配置工作区目录'
}
}
const gitMetadata = await stat(join(rootPath, '.git')).catch(
() => undefined
)
if (!gitMetadata) {
return {
rootPath,
available: false,
status: '',
patch: '',
files: [],
truncated: false
}
}
try {
const [status, patch] = await Promise.all([
runGit(rootPath, [
@@ -1,6 +1,7 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { strToU8, zipSync } from 'fflate'
import {
afterEach,
describe,
@@ -265,6 +266,62 @@ describe('CapabilityService', () => {
).rejects.toThrow('只能删除已导入')
})
it('imports a managed Skill from a ZIP package', async () => {
const { directory, importedRoot, service } = await createService()
const packageRoot = join(directory, 'zip-source')
await writeSkill(packageRoot, 'meeting-helper', '会议助手')
const skillMarkdown = await readFile(
join(packageRoot, 'meeting-helper', 'SKILL.md')
)
const archivePath = join(directory, 'meeting-helper.zip')
await writeFile(
archivePath,
zipSync({
'meeting-helper/SKILL.md': skillMarkdown,
'meeting-helper/template.txt': strToU8('template')
})
)
const imported = await service.importSkill(archivePath)
expect(imported.skills).toContainEqual(
expect.objectContaining({
id: 'meeting-helper',
source: 'imported'
})
)
await expect(
readFile(
join(importedRoot, 'meeting-helper', 'template.txt'),
'utf8'
)
).resolves.toBe('template')
})
it('rejects unsafe paths in a Skill ZIP package', async () => {
const { directory, importedRoot, service } = await createService()
const packageRoot = join(directory, 'unsafe-source')
await writeSkill(packageRoot, 'unsafe-skill', '不安全 Skill')
const skillMarkdown = await readFile(
join(packageRoot, 'unsafe-skill', 'SKILL.md')
)
const archivePath = join(directory, 'unsafe-skill.zip')
await writeFile(
archivePath,
zipSync({
'../escape.txt': strToU8('escape'),
'unsafe-skill/SKILL.md': skillMarkdown
})
)
await expect(service.importSkill(archivePath)).rejects.toThrow(
'Skill ZIP 包含不安全路径'
)
await expect(
readFile(join(importedRoot, 'escape.txt'), 'utf8')
).rejects.toThrow()
})
it('encrypts remote MCP secrets and never returns them publicly', async () => {
const { filePath, service } = await createService()
const snapshot = await service.saveMcpServer(undefined, {
+172 -28
View File
@@ -1,4 +1,5 @@
import { createHash, randomUUID } from 'node:crypto'
import { unzipSync } from 'fflate'
import {
lstat,
mkdir,
@@ -10,7 +11,7 @@ import {
stat,
writeFile
} from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import { basename, dirname, extname, join } from 'node:path'
import { parse as parseYaml } from 'yaml'
import { z } from 'zod'
import {
@@ -207,7 +208,7 @@ function defaultSkillState(): z.infer<typeof skillStateSchema> {
async function readSkill(
directoryPath: string,
source: SkillSummary['source'],
expectedId = basename(directoryPath)
expectedId: string | null = basename(directoryPath)
): Promise<Omit<SkillSummary, 'enabled' | 'assignments'>> {
const filePath = join(directoryPath, 'SKILL.md')
const file = await stat(filePath)
@@ -220,7 +221,7 @@ async function readSkill(
throw new Error(`${basename(directoryPath)} 的 SKILL.md 格式无效`)
}
const metadata = skillMetadataSchema.parse(parseYaml(match[1]))
if (metadata.id !== expectedId) {
if (expectedId !== null && metadata.id !== expectedId) {
throw new Error(`Skill ID 必须与目录名一致:${metadata.id}`)
}
return skillSummarySchema
@@ -309,6 +310,132 @@ async function copySkillPackage(
await copyDirectory(sourceRoot, targetRoot, 0)
}
function parseSkillZipPath(path: string): string[] {
const normalized = path.replaceAll('\\', '/')
const withoutTrailingSlash = normalized.replace(/\/+$/u, '')
if (
!withoutTrailingSlash ||
normalized.startsWith('/') ||
/^[a-z]:/iu.test(normalized)
) {
throw new Error('Skill ZIP 包含不安全路径')
}
const segments = withoutTrailingSlash.split('/')
if (
segments.some(
(segment) =>
!segment ||
segment === '.' ||
segment === '..' ||
segment.length > 255 ||
[...segment].some((character) => {
const code = character.charCodeAt(0)
return code <= 31 || code === 127
})
) ||
normalized.length > 512
) {
throw new Error('Skill ZIP 包含不安全路径')
}
return segments
}
function isIgnoredSkillZipPath(segments: readonly string[]): boolean {
return (
segments[0] === '__MACOSX' ||
segments.at(-1) === '.DS_Store'
)
}
async function extractSkillZip(
archivePath: string,
targetRoot: string
): Promise<string | undefined> {
const archiveDetails = await stat(archivePath)
if (
!archiveDetails.isFile() ||
archiveDetails.size > MAX_SKILL_PACKAGE_BYTES
) {
throw new Error('Skill ZIP 文件无效或过大')
}
const archiveBytes = await readFile(archivePath)
const selectedPaths = new Map<string, string[]>()
const normalizedPaths = new Set<string>()
let fileCount = 0
let totalBytes = 0
let files: Record<string, Uint8Array>
try {
files = unzipSync(archiveBytes, {
filter: (file) => {
const segments = parseSkillZipPath(file.name)
if (
file.name.endsWith('/') ||
isIgnoredSkillZipPath(segments)
) {
return false
}
const normalizedPath = segments.join('/').toLowerCase()
if (normalizedPaths.has(normalizedPath)) {
throw new Error('Skill ZIP 包含重复文件路径')
}
normalizedPaths.add(normalizedPath)
fileCount += 1
totalBytes += file.originalSize
if (
fileCount > MAX_SKILL_PACKAGE_FILES ||
file.originalSize > MAX_SKILL_FILE_BYTES ||
totalBytes > MAX_SKILL_PACKAGE_BYTES
) {
throw new Error('Skill ZIP 大小或文件数量超过安全限制')
}
selectedPaths.set(file.name, segments)
return true
}
})
} catch (error) {
if (error instanceof Error && error.message.startsWith('Skill ZIP')) {
throw error
}
throw new Error('Skill ZIP 文件无效或不受支持', {
cause: error
})
}
const skillEntries = [...selectedPaths.entries()].filter(
([, segments]) => segments.at(-1) === 'SKILL.md'
)
if (skillEntries.length !== 1) {
throw new Error('Skill ZIP 必须且只能包含一个 SKILL.md')
}
const packageRoot = skillEntries[0]![1].slice(0, -1)
const packageRootKey = packageRoot
.map((segment) => segment.toLowerCase())
for (const segments of selectedPaths.values()) {
const belongsToPackage = packageRootKey.every(
(segment, index) => segments[index]?.toLowerCase() === segment
)
if (!belongsToPackage || segments.length <= packageRoot.length) {
throw new Error('Skill ZIP 只能包含一个 Skill 包')
}
if (segments.length - packageRoot.length - 1 > MAX_SKILL_DEPTH) {
throw new Error('Skill ZIP 目录层级超过安全限制')
}
}
await mkdir(targetRoot, { recursive: true })
for (const [archiveName, contents] of Object.entries(files)) {
const segments = selectedPaths.get(archiveName)
if (!segments) {
continue
}
const relativeSegments = segments.slice(packageRoot.length)
const targetPath = join(targetRoot, ...relativeSegments)
await mkdir(dirname(targetPath), { recursive: true })
await writeFile(targetPath, contents, { mode: 0o600 })
}
return packageRoot.at(-1)
}
export class CapabilityService {
private state?: StoredCapabilities
private loadPromise?: Promise<StoredCapabilities>
@@ -819,21 +946,13 @@ export class CapabilityService {
importSkill(sourcePath: string): Promise<CapabilitySnapshot> {
return this.queue(async () => {
const canonicalSource = await realpath(sourcePath)
if (!(await stat(canonicalSource)).isDirectory()) {
throw new Error('所选 Skill 路径不是目录')
}
const skill = await readSkill(canonicalSource, 'imported')
const builtins = await listSkills(this.builtinSkillsRoot, 'builtin')
if (builtins.some((item) => item.id === skill.id)) {
throw new Error('导入的 Skill ID 与内置 Skill 冲突')
}
const targetPath = join(this.importedSkillsRoot, skill.id)
if (
await stat(targetPath)
.then(() => true)
.catch(() => false)
) {
throw new Error('同名 Skill 已导入,请先删除后重试')
const sourceDetails = await stat(canonicalSource)
const isDirectory = sourceDetails.isDirectory()
const isZip =
sourceDetails.isFile() &&
extname(canonicalSource).toLowerCase() === '.zip'
if (!isDirectory && !isZip) {
throw new Error('所选 Skill 路径必须是目录或 .zip 文件')
}
await mkdir(this.importedSkillsRoot, { recursive: true })
const temporaryPath = join(
@@ -841,22 +960,47 @@ export class CapabilityService {
`.import-${randomUUID()}`
)
try {
await copySkillPackage(canonicalSource, temporaryPath)
const archiveDirectoryName = isZip
? await extractSkillZip(canonicalSource, temporaryPath)
: undefined
const skill = await readSkill(
isDirectory ? canonicalSource : temporaryPath,
'imported',
isDirectory ? undefined : (archiveDirectoryName ?? null)
)
const builtins = await listSkills(
this.builtinSkillsRoot,
'builtin'
)
if (builtins.some((item) => item.id === skill.id)) {
throw new Error('导入的 Skill ID 与内置 Skill 冲突')
}
const targetPath = join(this.importedSkillsRoot, skill.id)
if (
await stat(targetPath)
.then(() => true)
.catch(() => false)
) {
throw new Error('同名 Skill 已导入,请先删除后重试')
}
if (isDirectory) {
await copySkillPackage(canonicalSource, temporaryPath)
}
await readSkill(temporaryPath, 'imported', skill.id)
await rename(temporaryPath, targetPath)
const state = await this.load()
await this.persist({
...state,
skills: {
...state.skills,
[skill.id]: defaultSkillState()
}
})
return this.getSnapshot()
} catch (error) {
await rm(temporaryPath, { recursive: true, force: true })
throw error
}
const state = await this.load()
await this.persist({
...state,
skills: {
...state.skills,
[skill.id]: defaultSkillState()
}
})
return this.getSnapshot()
})
}
+48
View File
@@ -0,0 +1,48 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { showDesktopNotificationWhenUnfocused } from './desktop-notification'
const notificationMocks = vi.hoisted(() => ({
isSupported: vi.fn(() => true),
show: vi.fn()
}))
vi.mock('electron', () => ({
Notification: class {
static isSupported = notificationMocks.isSupported
show = notificationMocks.show
}
}))
describe('showDesktopNotificationWhenUnfocused', () => {
beforeEach(() => {
vi.clearAllMocks()
notificationMocks.isSupported.mockReturnValue(true)
})
it('suppresses desktop notifications while GoodBuddy is focused', () => {
const shown = showDesktopNotificationWhenUnfocused(
{
isDestroyed: vi.fn(() => false),
isFocused: vi.fn(() => true)
} as never,
{ title: '任务已完成' }
)
expect(shown).toBe(false)
expect(notificationMocks.show).not.toHaveBeenCalled()
})
it('shows desktop notifications while GoodBuddy is unfocused', () => {
const shown = showDesktopNotificationWhenUnfocused(
{
isDestroyed: vi.fn(() => false),
isFocused: vi.fn(() => false)
} as never,
{ title: '任务已完成' }
)
expect(shown).toBe(true)
expect(notificationMocks.show).toHaveBeenCalledOnce()
})
})
+20
View File
@@ -0,0 +1,20 @@
import {
Notification,
type BrowserWindow,
type NotificationConstructorOptions
} from 'electron'
export function showDesktopNotificationWhenUnfocused(
window: BrowserWindow,
options: NotificationConstructorOptions
): boolean {
if (
window.isDestroyed() ||
window.isFocused() ||
!Notification.isSupported()
) {
return false
}
new Notification(options).show()
return true
}
+5 -2
View File
@@ -382,14 +382,17 @@ if (hasSingleInstanceLock) {
)
}
const createSelectedRuntime = async (
selection: AgentRuntimeSelection
selection: AgentRuntimeSelection,
workspacePath?: string
): Promise<AgentRuntime> => {
const resolved = applyRuntimeSelection(
await settingsStore.getResolvedSettings(),
selection
)
return createRuntimeWithCapabilities(
resolved.settings,
workspacePath
? { ...resolved.settings, workspacePath }
: resolved.settings,
resolved.target
)
}
+41 -3
View File
@@ -18,6 +18,10 @@ const electronMocks = vi.hoisted(() => {
removeHandler: vi.fn((channel: string) => {
handlers.delete(channel)
}),
showOpenDialog: vi.fn(async () => ({
canceled: true,
filePaths: [] as string[]
})),
openPath: vi.fn(async () => ''),
showItemInFolder: vi.fn(),
openExternal: vi.fn(async () => undefined)
@@ -78,6 +82,7 @@ describe('registerIpcHandlers computer capabilities', () => {
browserProfiles: { profiles: [], defaultProfileId: null }
}
const capabilityService = {
importSkill: vi.fn(async () => snapshot),
setComputerCapabilityEnabled: vi.fn(async () => snapshot),
createBrowserProfile: vi.fn(async () => snapshot),
diagnoseComputerCapability: vi.fn(async () => ({
@@ -131,6 +136,31 @@ describe('registerIpcHandlers computer capabilities', () => {
).toHaveBeenCalledWith('host-browser-control', true)
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
electronMocks.showOpenDialog.mockResolvedValueOnce({
canceled: false,
filePaths: ['C:\\meeting-helper.zip']
})
await expect(
electronMocks.handlers.get(
ipcChannels.capabilitiesImportSkill
)?.(event, 'zip')
).resolves.toEqual(snapshot)
expect(electronMocks.showOpenDialog).toHaveBeenCalledWith(
window,
expect.objectContaining({
properties: ['openFile'],
filters: [{ name: 'Skill ZIP', extensions: ['zip'] }]
})
)
expect(capabilityService.importSkill).toHaveBeenCalledWith(
'C:\\meeting-helper.zip'
)
await expect(
electronMocks.handlers.get(
ipcChannels.capabilitiesImportSkill
)?.(event, 'unsupported')
).rejects.toThrow()
browserStateListener?.({
conversationId: 'browser-conversation',
status: 'ready',
@@ -183,7 +213,9 @@ vi.mock('electron', () => ({
getVersion: vi.fn(() => '0.1.0')
},
BrowserWindow: class {},
dialog: {},
dialog: {
showOpenDialog: electronMocks.showOpenDialog
},
ipcMain: {
handle: electronMocks.handle,
removeHandler: electronMocks.removeHandler
@@ -838,7 +870,11 @@ describe('registerIpcHandlers agent terminal state', () => {
upsertModelUsageCall: vi.fn(),
clearAssistantData: vi.fn(),
listExperts: vi.fn<() => Array<Record<string, unknown>>>(() => []),
getExpert: vi.fn()
getExpert: vi.fn(),
getProject: vi.fn((projectId: string) => ({
id: projectId,
rootPath: 'C:\\ProjectWorkspace'
}))
}
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
@@ -1229,6 +1265,7 @@ describe('registerIpcHandlers agent terminal state', () => {
harness.handler?.(event, {
requestId: '00000000-0000-4000-8000-000000000011',
conversationId: 'conversation-one',
projectId: '00000000-0000-4000-8000-000000000101',
prompt: 'first request',
workMode: 'ask',
runtimeSelection: firstSelection
@@ -1258,7 +1295,8 @@ describe('registerIpcHandlers agent terminal state', () => {
})
expect(fallbackRuntime.run).not.toHaveBeenCalled()
expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith(
firstSelection
firstSelection,
'C:\\ProjectWorkspace'
)
expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith(
secondSelection
+144 -45
View File
@@ -3,7 +3,6 @@ import {
BrowserWindow,
dialog,
ipcMain,
Notification,
shell
} from 'electron'
import { mkdir, readFile, realpath, stat } from 'node:fs/promises'
@@ -14,6 +13,7 @@ import { z } from 'zod'
import { formatShortcutForDisplay } from '../shared/shortcut'
import {
approvalDecisionSchema,
agentQuestionResponseSchema,
agentRequestSchema,
browserStopRequestSchema,
knowledgeCreateSchema,
@@ -30,8 +30,10 @@ import {
windowCaptureRequestSchema,
workspaceDirectoryRequestSchema,
workspaceFileRequestSchema,
workspaceOpenPathRequestSchema,
type AgentRuntimeDetection,
type AgentEvent,
type AgentRequest,
type AppInfo,
type BrowserLiveState,
type KnowledgeSnapshot,
@@ -49,6 +51,7 @@ import {
mcpServerInputSchema,
skillAssignmentsInputSchema,
skillIdSchema,
skillImportKindSchema,
skillToggleInputSchema,
type CapabilitySnapshot,
type CapabilityDiagnosticReport,
@@ -111,9 +114,11 @@ import { RemoteDelegationService } from './assistant/remote-delegation-service'
import {
getWorkspaceChanges,
listWorkspaceDirectory,
readWorkspaceFile
readWorkspaceFile,
resolveWorkspaceEntryPath
} from './assistant/workspace-changes-service'
import { HeartbeatService } from './assistant/heartbeat-service'
import { showDesktopNotificationWhenUnfocused } from './desktop-notification'
import {
SubagentRunError,
type SubagentService
@@ -238,6 +243,12 @@ const projectArchiveRequestSchema = z
archived: z.boolean()
})
.strict()
const projectDeleteRequestSchema = z
.object({
projectId: assistantIdSchema,
confirmation: z.string().max(120)
})
.strict()
const memoryStatusRequestSchema = z
.object({
memoryId: assistantIdSchema,
@@ -494,6 +505,10 @@ export function registerIpcHandlers(
knowledgeGateway?: KnowledgeMcpGateway
): () => Promise<void> {
const activeRequests = new Map<string, AbortController>()
const pendingAgentQuestions = new Map<
string,
{ requestId: string; runtime: AgentRuntime }
>()
const heartbeatControllers = new Set<AbortController>()
let shuttingDown = false
let executionPaused = false
@@ -506,6 +521,21 @@ export function registerIpcHandlers(
)
return execution
}
const resolveRequestRuntime = async (
request: Pick<AgentRequest, 'projectId' | 'runtimeSelection'>
): Promise<AgentRuntime> => {
const projectWorkspace = request.projectId
? assistantDatabase.getProject(request.projectId).rootPath.trim()
: ''
if (!selectedRuntimes || (!request.runtimeSelection && !projectWorkspace)) {
return runtime
}
const selection =
request.runtimeSelection ?? ({ provider: 'auto' } as const)
return projectWorkspace
? selectedRuntimes.getRuntime(selection, projectWorkspace)
: selectedRuntimes.getRuntime(selection)
}
const channels = Object.values(ipcChannels).filter(
(channel) =>
channel !== ipcChannels.agentEvent &&
@@ -618,7 +648,10 @@ export function registerIpcHandlers(
assistantDatabase,
{
summarize: async (request) => {
if (runtime.capability === 'image-generation') {
const requestRuntime = await resolveRequestRuntime({
projectId: request.projectId
})
if (requestRuntime.capability === 'image-generation') {
throw new Error('智能心跳需要文本模型,当前默认连接仅支持图像生成')
}
const controller = new AbortController()
@@ -644,10 +677,11 @@ export function registerIpcHandlers(
let output = ''
let completed = false
try {
for await (const event of runtime.run(
for await (const event of requestRuntime.run(
{
requestId,
conversationId,
projectId: request.projectId,
workMode: 'ask',
prompt: [
request.systemInstruction,
@@ -704,7 +738,7 @@ export function registerIpcHandlers(
} finally {
clearTimeout(timeout)
heartbeatControllers.delete(controller)
await runtime.releaseConversation?.(conversationId)
await requestRuntime.releaseConversation?.(conversationId)
}
}
},
@@ -755,7 +789,10 @@ export function registerIpcHandlers(
let output = ''
let completed = false
try {
for await (const agentEvent of runtime.run(
const requestRuntime = await resolveRequestRuntime({
projectId: schedule.projectId
})
for await (const agentEvent of requestRuntime.run(
{
requestId,
conversationId: `${origin}:${schedule.id}`,
@@ -841,12 +878,10 @@ export function registerIpcHandlers(
})
}
assistantDatabase.updateTaskStatus(requestId, 'completed')
if (Notification.isSupported()) {
new Notification({
title: `定时任务完成:${schedule.title}`,
body: '结果已保存到 GoodBuddy 成果工作栏。'
}).show()
}
showDesktopNotificationWhenUnfocused(window, {
title: `定时任务完成:${schedule.title}`,
body: '结果已保存到 GoodBuddy 成果工作栏。'
})
return { status: 'completed', output }
} catch (error) {
const message = safeRuntimeError(error, '定时任务执行失败')
@@ -855,12 +890,10 @@ export function registerIpcHandlers(
controller.signal.aborted ? 'cancelled' : 'failed',
message
)
if (Notification.isSupported()) {
new Notification({
title: `定时任务失败:${schedule.title}`,
body: '打开 GoodBuddy 任务工作栏查看详情。'
}).show()
}
showDesktopNotificationWhenUnfocused(window, {
title: `定时任务失败:${schedule.title}`,
body: '打开 GoodBuddy 任务工作栏查看详情。'
})
return { status: 'failed', error: message }
} finally {
externalSignal?.removeEventListener(
@@ -1182,12 +1215,7 @@ export function registerIpcHandlers(
throw new Error('请求包含不存在的知识库')
}
}
const selectedRuntime =
parsedInput.runtimeSelection && selectedRuntimes
? await selectedRuntimes.getRuntime(
parsedInput.runtimeSelection
)
: runtime
const selectedRuntime = await resolveRequestRuntime(parsedInput)
const normalizedWorkMode = normalizeInteractiveWorkMode(
parsedInput.workMode
)
@@ -1381,6 +1409,12 @@ export function registerIpcHandlers(
if (publicEvent.type === 'tool') {
toolStates.set(publicEvent.callId, publicEvent)
}
if (publicEvent.type === 'question') {
pendingAgentQuestions.set(publicEvent.questionId, {
requestId: request.requestId,
runtime: selectedRuntime
})
}
if (publicEvent.type === 'error') {
assistantDatabase.appendTaskEvent(
request.requestId,
@@ -1446,12 +1480,10 @@ export function registerIpcHandlers(
request.requestId,
'completed'
)
if (!window.isFocused() && Notification.isSupported()) {
new Notification({
title: 'GoodBuddy 任务已完成',
body: '任务结果已保存到成果工作栏。'
}).show()
}
showDesktopNotificationWhenUnfocused(window, {
title: 'GoodBuddy 任务已完成',
body: '任务结果已保存到成果工作栏。'
})
}
if (!window.isDestroyed()) {
window.webContents.send(ipcChannels.agentEvent, publicEvent)
@@ -1485,18 +1517,21 @@ export function registerIpcHandlers(
agentEvent
)
}
if (!window.isFocused() && Notification.isSupported()) {
new Notification({
title: controller.signal.aborted
? 'GoodBuddy 任务已取消'
: 'GoodBuddy 任务失败',
body: '打开任务工作栏查看详情。'
}).show()
}
showDesktopNotificationWhenUnfocused(window, {
title: controller.signal.aborted
? 'GoodBuddy 任务已取消'
: 'GoodBuddy 任务失败',
body: '打开任务工作栏查看详情。'
})
if (!window.isDestroyed()) {
window.webContents.send(ipcChannels.agentEvent, agentEvent)
}
} finally {
for (const [questionId, pending] of pendingAgentQuestions) {
if (pending.requestId === request.requestId) {
pendingAgentQuestions.delete(questionId)
}
}
knowledgeGateway?.revoke(request.knowledgeCapabilityToken)
activeRequests.delete(request.requestId)
}
@@ -1515,6 +1550,22 @@ export function registerIpcHandlers(
const response = approvalResponseSchema.parse(input)
approvalBroker.respond(response.approvalId, response.decision)
})
ipcMain.handle(
ipcChannels.agentQuestionRespond,
async (event, input: unknown) => {
assertTrustedSender(event, window)
const response = agentQuestionResponseSchema.parse(input)
const pending = pendingAgentQuestions.get(response.questionId)
if (!pending?.runtime.respondToQuestion) {
throw new Error('OpenCode 提问已失效或不存在')
}
await pending.runtime.respondToQuestion(
response.questionId,
response.answers.length > 0 ? response.answers : undefined
)
pendingAgentQuestions.delete(response.questionId)
}
)
ipcMain.handle(
ipcChannels.runtimeSettingsGet,
@@ -2016,10 +2067,15 @@ export function registerIpcHandlers(
ipcMain.handle(
ipcChannels.projectsUpdate,
(event, input: unknown) => {
async (event, input: unknown) => {
assertTrustedSender(event, window)
const value = projectUpdateRequestSchema.parse(input)
return assistantDatabase.updateProject(value.projectId, value.input)
const project = assistantDatabase.updateProject(
value.projectId,
value.input
)
await selectedRuntimes?.reset?.()
return project
}
)
@@ -2034,6 +2090,18 @@ export function registerIpcHandlers(
)
}
)
ipcMain.handle(
ipcChannels.projectsDelete,
async (event, input: unknown) => {
assertTrustedSender(event, window)
const value = projectDeleteRequestSchema.parse(input)
assistantDatabase.deleteProject(
value.projectId,
value.confirmation
)
await selectedRuntimes?.reset?.()
}
)
ipcMain.handle(ipcChannels.conversationsList, (event) => {
assertTrustedSender(event, window)
@@ -2078,6 +2146,27 @@ export function registerIpcHandlers(
return readWorkspaceFile(project.rootPath, value.path)
}
)
ipcMain.handle(
ipcChannels.workspacePathOpen,
async (event, input: unknown) => {
assertTrustedSender(event, window)
const value = workspaceOpenPathRequestSchema.parse(input)
const project = assistantDatabase.getProject(value.projectId)
const targetPath = await resolveWorkspaceEntryPath(
project.rootPath,
value.path,
value.type
)
const error = await shell.openPath(targetPath)
if (error) {
throw new Error(
value.type === 'directory'
? '无法在系统资源管理器中打开文件夹'
: '无法使用系统默认应用打开文件'
)
}
}
)
ipcMain.handle(ipcChannels.tasksList, (event) => {
assertTrustedSender(event, window)
@@ -2337,12 +2426,22 @@ export function registerIpcHandlers(
ipcMain.handle(
ipcChannels.capabilitiesImportSkill,
async (event): Promise<CapabilitySnapshot> => {
async (event, input: unknown): Promise<CapabilitySnapshot> => {
assertTrustedSender(event, window)
const result = await dialog.showOpenDialog(window, {
title: '选择包含 SKILL.md 的目录',
properties: ['openDirectory']
})
const kind = skillImportKindSchema.parse(input)
const result = await dialog.showOpenDialog(
window,
kind === 'zip'
? {
title: '选择 Skill ZIP 文件',
properties: ['openFile'],
filters: [{ name: 'Skill ZIP', extensions: ['zip'] }]
}
: {
title: '选择包含 SKILL.md 的目录',
properties: ['openDirectory']
}
)
if (result.canceled || !result.filePaths[0]) {
return capabilityService.getSnapshot()
}