fix: preserve tool details and prevent repeated history growth

Show bounded operation summaries for Continue, DeepSeek Harness and direct-model tools, retaining input details across incremental updates. Use one canonical workspace path for DeepSeek Host startup and ACP sessions so equivalent Windows paths reuse a working Runtime.

Compare persisted subagent tool fields by value, release terminal task caches, and avoid rebuilding caches for duplicate remote events. Upgrade to schema 35 to recompact repeated schema-34 tool blocks while preserving events and provenance. Include regression coverage, validation records and temporary-resource test exclusions.

Validation: 4155 tests passed, 66 skipped; typecheck and lint passed. Full desktop real-model validation covered all three tool-summary paths and both Windows path spellings. Storage validation evidence includes local and remote tool workloads and real database upgrade checks.

Release note: Tool records now show paths, commands and search details across runtimes. Fix DeepSeek Windows workspace failures and repeated subagent history growth; schema 35 reclaims redundant history data while preserving execution records, and older clients cannot reopen the upgraded database.
This commit is contained in:
mesalogo
2026-09-14 00:34:51 +08:00
parent c71da4b6ae
commit 0d7ba55a50
29 changed files with 667 additions and 97 deletions
+11 -7
View File
@@ -1603,7 +1603,7 @@ describe('ContinueHostAdapter', () => {
expect(existsSync(globalDirectory)).toBe(false)
})
it('uses auto mode and returns audit metadata for agent tools', async () => {
it.each(['completed', 'failed'] as const)('uses auto mode and retains streamed audit metadata on %s', async (toolState) => {
const distribution = await createDistribution()
let launchArgs: string[] = []
const permissionBodies: unknown[] = []
@@ -1681,7 +1681,7 @@ describe('ContinueHostAdapter', () => {
toolCall: {
function: { name: 'Bash' }
},
status: 'done'
status: toolState
}
]
}
@@ -1695,7 +1695,7 @@ describe('ContinueHostAdapter', () => {
type: 'tool',
callId: 'call-1',
name: 'Bash',
state: 'completed',
state: toolState,
output:
'Tests passed\nAuthorization: Bearer secret-token'
},
@@ -1737,9 +1737,10 @@ describe('ContinueHostAdapter', () => {
{
callId: 'call-1',
name: 'Bash',
state: 'completed',
state: toolState,
input:
'{"command":"npm test","token":"secret-token"}',
summary: 'npm test',
output:
'Tests passed\nAuthorization: Bearer secret-token'
}
@@ -1754,7 +1755,8 @@ describe('ContinueHostAdapter', () => {
name: 'Bash',
state: 'running',
input:
'{"command":"npm test","token":"secret-token"}'
'{"command":"npm test","token":"secret-token"}',
summary: 'npm test'
}
},
{
@@ -1762,9 +1764,11 @@ describe('ContinueHostAdapter', () => {
tool: {
callId: 'call-1',
name: 'Bash',
state: 'completed',
state: toolState,
output:
'Tests passed\nAuthorization: Bearer secret-token'
'Tests passed\nAuthorization: Bearer secret-token',
input: '{"command":"npm test","token":"secret-token"}',
summary: 'npm test'
}
},
{ type: 'text', delta: 'TOOLS_OK' }
+18 -1
View File
@@ -1,4 +1,5 @@
import spawn from 'cross-spawn'
import { toolOperationSummary } from './tool-operation-summary'
import { createHash, randomBytes } from 'node:crypto'
import {
copyFile,
@@ -112,6 +113,7 @@ const continueHostStreamEventSchema = z.discriminatedUnion('type', [
name: z.string().min(1).max(200),
state: z.enum(['running', 'completed', 'failed']),
input: z.string().max(4_000).optional(),
summary: z.string().max(240).optional(),
output: z.string().max(16_000).optional(),
error: z.string().max(1_000).optional()
})
@@ -181,6 +183,7 @@ export type ContinueHostTool = {
name: string
state: 'pending' | 'running' | 'completed' | 'failed'
input?: string
summary?: string
output?: string
error?: string
}
@@ -729,6 +732,10 @@ function extractContinueTools(
callId,
name: name.trim().slice(0, 200),
state: normalizedState,
summary: toolOperationSummary(
(toolFunction as Record<string, unknown>).arguments,
state.title
),
...(input ? { input } : {}),
...(output ? { output } : {}),
...(error ? { error } : {})
@@ -749,6 +756,7 @@ function mergeContinueTools(
...previous,
...tool,
input: tool.input ?? previous?.input,
summary: tool.summary ?? previous?.summary,
output: tool.output ?? previous?.output,
error: tool.error ?? previous?.error
})
@@ -976,6 +984,11 @@ export class ContinueHostAdapter {
serverStateMarker,
'pendingPermission:null,goodbuddyEvents:[],goodbuddyEventsBytes:0,goodbuddyEventsOverflow:!1},B='
)
patched = replaceExactly(
patched,
'state:"running",input:(()=>',
`state:"running",summary:(${toolOperationSummary.toString()})(l),input:(()=>`
)
patched = replaceExactly(
patched,
serverStateEndpointMarker,
@@ -1682,6 +1695,7 @@ export class ContinueHostAdapter {
callId: event.callId,
name: event.name,
state: event.state,
summary: toolOperationSummary(event.input, event.summary),
...(event.input
? { input: boundedToolDetail(event.input, 4_000) }
: {}),
@@ -1693,7 +1707,10 @@ export class ContinueHostAdapter {
: {})
}
observedTools = mergeContinueTools(observedTools, [tool])
await runOptions.onEvent?.({ type: 'tool', tool })
await runOptions.onEvent?.({
type: 'tool',
tool: observedTools.find((item) => item.callId === tool.callId)!
})
}
const pendingQuestion = state.goodbuddyQuestion
if (
+1 -1
View File
@@ -1005,7 +1005,7 @@ describe('ContinueAgentRuntime', () => {
type: 'tool',
name: 'Bash',
state: 'completed',
summary: 'Continue 工具:Bash',
summary: 'npm test',
input: '{"command":"npm test"}',
output: 'Tests passed'
}),
+2 -1
View File
@@ -1,4 +1,5 @@
import { createHash } from 'node:crypto'
import { toolOperationSummary } from './tool-operation-summary'
import type {
AgentQuestionAnswer,
AgentEvent,
@@ -88,7 +89,7 @@ function toContinueToolEvent(
: terminalize && tool.state !== 'completed'
? 'failed'
: tool.state,
summary: `Continue 工具:${tool.name}`,
summary: toolOperationSummary(tool.input, tool.summary) ?? `Continue 工具:${tool.name}`,
...(tool.input ? { input: tool.input } : {}),
...(tool.output ? { output: tool.output } : {}),
...(tool.error ? { error: tool.error } : {})
@@ -35,6 +35,8 @@ import { DshNpmExtensionInstaller } from './dsh-extension-marketplace'
import { DEEPSEEK_HARNESS_MAX_FRAME_BYTES } from './deepseek-harness-control-protocol'
import { createOpenAIChatCompletionsUrl } from './openai-endpoint'
import { createModelRequestProbe } from '../../../tests/support/model-request-probe'
import { ExecutionSpaceResolver } from '../execution-space/execution-space-resolver'
import { SelectedRuntimeManager } from './selected-runtime-manager'
const CREDENTIAL_REF = 'GOODBUDDY_HARNESS_MODEL_API_KEY'
const SKILL_CALL_ID = 'e2e-skill-call'
@@ -383,6 +385,49 @@ function createInProcessLaunch(
}
describe('DeepSeek Harness real ACP control-plane E2E', () => {
it.skipIf(process.platform !== 'win32')('reuses a working runtime for mixed-separator and canonical Windows paths', async () => {
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-path-')))
const workspace = join(root, 'workspace')
const dshHome = join(root, 'home')
await Promise.all([mkdir(workspace), mkdir(dshHome)])
const canonical = await realpath(workspace)
const mixed = `${root.replaceAll('\\', '/')}\\workspace`
const resolver = new ExecutionSpaceResolver()
const inProcess = createInProcessLaunch(dshHome, {
stream: () => textResponse('PATH_OK')
})
const launch = vi.fn(inProcess.launch)
const manager = new SelectedRuntimeManager(async (_selection, space) => {
if (space?.kind !== 'local') throw new Error('local workspace required')
return new DeepSeekHarnessRuntime({
defaultWorkspace: space.rootPath, baseUrl: 'https://api.deepseek.com',
model: 'path-test', launch,
credentialRefs: { [CREDENTIAL_REF]: 'unused-in-memory-model-credential' },
initializationTimeoutMs: 20_000, promptTimeoutMs: 20_000, shutdownTimeoutMs: 5_000
})
})
try {
expect(mixed).not.toBe(canonical)
expect(await realpath(mixed)).toBe(canonical)
const first = await manager.getRuntime({ provider: 'deepseek-harness' }, resolver.resolveLocal(mixed))
for (const [index, spelling] of [mixed, canonical].entries()) {
const runtime = await manager.getRuntime({ provider: 'deepseek-harness' }, resolver.resolveLocal(spelling))
expect(runtime).toBe(first)
const events = await collect(runtime.run({
requestId: `path-${index}`, conversationId: `path-${index}`,
workMode: 'ask', prompt: 'Return PATH_OK.'
}, AbortSignal.timeout(20_000)))
expect(events.at(-1)).toMatchObject({ type: 'done' })
expect(events.filter(event => event.type === 'text').map(event => event.delta).join('')).toBe('PATH_OK')
}
expect(launch).toHaveBeenCalledOnce()
expect(launch.mock.calls[0]?.[0].cwd).toBe(canonical)
} finally {
await manager.dispose()
await rm(root, { recursive: true, force: true })
}
})
it('writes outside the Workspace in Execute without approval and rejects the same tool in Ask', async () => {
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-directory-')))
const workspace = join(root, 'workspace')
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { resolve } from 'node:path'
import { realpath } from 'node:fs/promises'
import type { RuntimeEvent } from './runtime'
import {
ModelToolProvider,
@@ -243,7 +244,7 @@ function setup(
})
)
const runtime = new DeepSeekHarnessRuntime({
defaultWorkspace: 'C:\\workspace',
defaultWorkspace: process.cwd(),
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-test',
supportsImageInput: options.supportsImageInput,
@@ -474,6 +475,7 @@ describe('DeepSeekHarnessRuntime', () => {
})
const status = harness.runtime.getStatus()
await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce())
await vi.advanceTimersByTimeAsync(10_001)
await expect(status).resolves.toMatchObject({
@@ -503,6 +505,7 @@ describe('DeepSeekHarnessRuntime', () => {
})
const status = harness.runtime.getStatus()
await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce())
await vi.advanceTimersByTimeAsync(12_000)
await expect(status).resolves.toMatchObject({
@@ -674,7 +677,7 @@ describe('DeepSeekHarnessRuntime', () => {
harness.child.stdout
)
expect(harness.launch).toHaveBeenCalledWith({
cwd: 'C:\\workspace',
cwd: await realpath(process.cwd()),
signal: expect.any(AbortSignal),
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-test',
@@ -710,7 +713,12 @@ describe('DeepSeekHarnessRuntime', () => {
await harness.runtime.dispose()
})
it('keeps the original tool name on generic completion updates', async () => {
it.each([
['completed', undefined, 'GoodBuddy'],
['failed', undefined, 'GoodBuddy'],
['completed', 'Search the release notes', 'Search the release notes'],
['failed', 'Search the release notes', 'Search the release notes']
] as const)('keeps tool input and summary on incremental %s updates with title %s', async (status, title, summary) => {
const harness = setup()
const running = collect(
harness.runtime.run(
@@ -725,6 +733,7 @@ describe('DeepSeekHarnessRuntime', () => {
sessionUpdate: 'tool_call',
toolCallId: 'call-web-search',
name: 'web_search',
title,
status: 'pending',
rawInput: { query: 'GoodBuddy' }
})
@@ -732,7 +741,7 @@ describe('DeepSeekHarnessRuntime', () => {
sessionUpdate: 'tool_call_update',
toolCallId: 'call-web-search',
name: 'tool',
status: 'completed',
status,
rawOutput: 'search result'
})
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
@@ -743,13 +752,16 @@ describe('DeepSeekHarnessRuntime', () => {
type: 'tool',
callId: 'call-web-search',
name: 'web_search',
state: 'pending'
state: 'pending',
summary
}),
expect.objectContaining({
type: 'tool',
callId: 'call-web-search',
name: 'web_search',
state: 'completed'
state: status,
summary,
input: expect.stringContaining('GoodBuddy')
})
])
)
+24 -9
View File
@@ -1,3 +1,5 @@
import { toolOperationSummary } from './tool-operation-summary'
import { realpath } from 'node:fs/promises'
import type {
AgentRuntimeStatus,
ModelRequestHeaders,
@@ -210,12 +212,16 @@ type ActiveRun = {
toolController: AbortController
authorize?: RuntimeAuthorizer
updates: AcpSessionNotification['update'][]
toolNames: Map<string, string>
toolNames: Map<
string,
{ name: string; input?: string; summary?: string; title?: string }
>
wake?: () => void
closed: boolean
}
type HarnessState = {
workspace: string
child: DeepSeekHarnessChild
connection: AcpConnection
agent: AcpAgent
@@ -778,9 +784,10 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
this.launchController = launchController
let child: DeepSeekHarnessChild | undefined
try {
const workspace = await realpath(this.options.defaultWorkspace)
child = await withTimeout(
this.options.launch({
cwd: this.options.defaultWorkspace,
cwd: workspace,
signal: launchController.signal,
baseUrl: this.options.baseUrl,
model: this.options.model,
@@ -977,6 +984,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
throw new Error('DeepSeek Harness ACP 客户端初始化失败')
}
const stateWithoutCapabilities = {
workspace,
child,
connection,
agent
@@ -1306,7 +1314,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
}
const creation = state.agent
.newSession({
cwd: this.options.defaultWorkspace,
cwd: state.workspace,
mcpServers: []
})
.then((response) => {
@@ -1336,7 +1344,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
private toRuntimeEvent(
requestId: string,
update: AcpSessionNotification['update'],
toolNames: Map<string, string>
toolNames: ActiveRun['toolNames']
): RuntimeEvent | undefined {
if (update.goodBuddyEvent) {
return this.toUsageEvent(
@@ -1378,16 +1386,23 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
'DeepSeek Harness 工具'
).slice(0, 200)
const callId = update.toolCallId.slice(0, 256)
const previous = toolNames.get(callId)
const name =
reportedName === 'tool'
? toolNames.get(callId) ?? reportedName
reportedName === 'tool' || (!update.name && !update.title)
? previous?.name ?? reportedName
: reportedName
const title = toolOperationSummary(
undefined,
update.title === 'tool' || update.title === update.name || update.title === previous?.name
? undefined : update.title
) ?? previous?.title
const input = safeStringify(update.rawInput) ?? previous?.input
const summary = toolOperationSummary(update.rawInput, title) ?? previous?.summary
if (state === 'pending' || state === 'running') {
toolNames.set(callId, name)
toolNames.set(callId, { name, input, summary, title })
} else {
toolNames.delete(callId)
}
const input = safeStringify(update.rawInput)
const output = safeStringify(update.rawOutput)
return {
requestId,
@@ -1395,7 +1410,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
callId,
name,
state,
summary: `DeepSeek Harness 工具:${name}`,
summary: summary ?? `DeepSeek Harness 工具:${name}`,
...(input ? { input } : {}),
...(output ? { output } : {})
}
+10 -5
View File
@@ -2473,7 +2473,10 @@ describe('ModelAgentRuntime', () => {
).toMatch(/GOODBUDDY_MODEL_TEST_[A-F0-9]+/u)
})
it('runs approved direct-model tools and returns their results to OpenAI', async () => {
it.each([false, true])('runs approved tools with summaries from raw arguments (long input: %s)', async (longInput) => {
const args = longInput
? { content: 'x'.repeat(5_000), path: 'README.md' }
: { path: 'README.md' }
const responses = [
{
model: 'qwen3',
@@ -2488,7 +2491,7 @@ describe('ModelAgentRuntime', () => {
type: 'function',
function: {
name: 'workspace_read_text',
arguments: '{"path":"README.md"}'
arguments: JSON.stringify(args)
}
}
]
@@ -2596,7 +2599,7 @@ describe('ModelAgentRuntime', () => {
)
expect(toolProvider.getApproval).toHaveBeenCalledWith(
expect.objectContaining({ name: 'workspace_read_text' }),
{ path: 'README.md' },
args,
expect.any(String),
expect.objectContaining({
conversationId: 'conversation-tools',
@@ -2605,7 +2608,7 @@ describe('ModelAgentRuntime', () => {
)
expect(toolProvider.callTool).toHaveBeenCalledWith(
'workspace_read_text',
{ path: 'README.md' },
args,
expect.any(AbortSignal),
expect.objectContaining({
conversationId: 'conversation-tools',
@@ -2617,11 +2620,13 @@ describe('ModelAgentRuntime', () => {
.filter((event) => event.type === 'tool')
.map((event) => event.state)
).toEqual(['pending', 'running', 'completed'])
expect(events.filter((event) => event.type === 'tool').map((event) => event.summary))
.toEqual(['README.md', 'README.md', 'README.md'])
expect(events).toContainEqual(
expect.objectContaining({
type: 'tool',
state: 'completed',
input: '{\n "path": "README.md"\n}',
input: longInput ? expect.any(String) : '{\n "path": "README.md"\n}',
output:
'tool result\n\n[图片结果 1:image/png]'
})
+5 -3
View File
@@ -1,4 +1,5 @@
import { createHash, randomBytes } from 'node:crypto'
import { toolOperationSummary } from './tool-operation-summary'
import { dirname } from 'node:path'
import type {
ApprovalDecision,
@@ -3752,6 +3753,7 @@ export class ModelAgentRuntime implements AgentRuntime {
tool?.name !== 'subagent_delegate'
const displayName = tool?.displayName ?? call.name.slice(0, 128)
const input = boundedToolDetail(call.arguments, 4_000)
const summary = toolOperationSummary(call.arguments) ?? `直连模型工具:${displayName}`
roundSummary.push(
[
`TOOL_CALL: ${displayName}`,
@@ -3767,7 +3769,7 @@ export class ModelAgentRuntime implements AgentRuntime {
callId: call.id,
name: displayName,
state: 'pending',
summary: `直连模型工具:${displayName}`,
summary,
input
}
}
@@ -3844,7 +3846,7 @@ export class ModelAgentRuntime implements AgentRuntime {
callId: call.id,
name: displayName,
state: 'running',
summary: `正在执行直连模型工具:${displayName}`,
summary,
input
}
}
@@ -3925,7 +3927,7 @@ export class ModelAgentRuntime implements AgentRuntime {
callId: call.id,
name: displayName,
state: 'completed',
summary: `直连模型工具已完成:${displayName}`,
summary,
input,
output: getToolResultPreview(result.parts)
}
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { toolOperationSummary } from './tool-operation-summary'
describe('toolOperationSummary', () => {
it.each(['path', 'filePath', 'file_path', 'filepath', 'dirpath'])(
'extracts %s without displaying file contents', (key) => {
expect(toolOperationSummary({ content: 'x'.repeat(10_000), [key]: 'src/main.ts' }))
.toBe('src/main.ts')
}
)
it.each([
[{ command: 'npm test\r\n-- --run', path: '/repo' }, 'npm test -- --run'],
[{ pattern: 'TODO', path: 'src', filePattern: '*.ts' }, 'TODO | src | *.ts'],
[{ query: 'latest release' }, 'latest release'],
[{ glob: '**/*.ts' }, '**/*.ts'],
[{ url: 'https://example.com' }, 'https://example.com']
])('extracts command, search and URL parameters', (args, expected) => {
expect(toolOperationSummary(JSON.stringify(args))).toBe(expected)
})
it.each([undefined, null, [], 12, 'broken JSON', '{"path":', '"text"', { content: 'body' }, { path: {}, command: 42 }])(
'falls back for invalid or irrelevant input %j', (input) => {
expect(toolOperationSummary(input)).toBeUndefined()
}
)
it('prefers a nonempty native title and bounds a single line to 240 characters', () => {
expect(toolOperationSummary({ path: 'file' }, 'Read\n native\tfile')).toBe('Read native file')
expect(toolOperationSummary({ path: 'file' }, ' \n ')).toBe('file')
expect(toolOperationSummary({ command: 'x'.repeat(500) })).toHaveLength(240)
expect(toolOperationSummary({}, 'x'.repeat(500))).toHaveLength(240)
})
it('can run in the Continue host bundle before input preview truncation', () => {
const summarize = new Function(`return (${toolOperationSummary.toString()})`)() as typeof toolOperationSummary
expect(summarize({ content: 'x'.repeat(10_000), filepath: 'report.md' })).toBe('report.md')
})
})
+34
View File
@@ -0,0 +1,34 @@
// Kept self-contained so the Continue host can use it before truncating input.
export function toolOperationSummary(
input: unknown,
title?: unknown
): string | undefined {
const singleLine = (value: unknown): string =>
typeof value === 'string'
? [...value].map((character) => {
const code = character.charCodeAt(0)
return code <= 31 || code === 127 ? ' ' : character
}).join('').replace(/\s+/gu, ' ').trim()
: ''
const nativeTitle = singleLine(title)
if (nativeTitle) return nativeTitle.slice(0, 240)
if (typeof input === 'string') {
try {
input = JSON.parse(input)
} catch {
return undefined
}
}
if (!input || typeof input !== 'object' || Array.isArray(input)) return undefined
const args = input as Record<string, unknown>
const first = (keys: string[]): string =>
keys.map((key) => singleLine(args[key])).find(Boolean) ?? ''
const command = first(['command'])
const path = first(['path', 'filePath', 'file_path', 'filepath', 'dirpath'])
const search = first(['query', 'pattern', 'glob'])
const filter = first(['filePattern', 'include'])
const summary = command || (search
? [search, path, filter].filter(Boolean).join(' | ')
: path || first(['url']))
return summary ? summary.slice(0, 240) : undefined
}