Files
goodbuddy/src/main/assistant/subagent-progress-storage.ts
T
mesalogo 0d7ba55a50 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.
2026-09-14 00:34:51 +08:00

240 lines
7.4 KiB
TypeScript

import { z } from 'zod'
import { isDeepStrictEqual } from 'node:util'
import type { DatabaseSync } from 'node:sqlite'
import {
assistantIdSchema,
conversationMessageBlockSchema,
conversationToolActivitySchema,
type ConversationMessageBlock,
type ConversationToolActivity
} from '../../shared/assistant-contracts'
export const SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION = 35
const toolFields = Object.keys(conversationToolActivitySchema.shape) as
Array<keyof ConversationToolActivity>
function sameProgressBlock(
previous: ConversationMessageBlock,
next: ConversationMessageBlock
): boolean {
if (previous.type === 'tool' && next.type === 'tool') {
// JSON omits undefined optional fields. Compare their values, not key presence.
return previous.id === next.id &&
toolFields.every((field) => previous.tool[field] === next.tool[field])
}
return isDeepStrictEqual(previous, next)
}
export const subagentProgressUpdateSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('append'),
id: assistantIdSchema,
blockType: z.enum(['text', 'reasoning']),
delta: z.string()
}).strict(),
z.object({
type: z.literal('upsert'),
block: conversationMessageBlockSchema
}).strict(),
z.object({
type: z.literal('reset'),
blocks: z.array(conversationMessageBlockSchema)
}).strict()
])
export type SubagentProgressUpdate = z.infer<
typeof subagentProgressUpdateSchema
>
type ProgressEvent = {
progress?: ConversationMessageBlock[]
progressUpdates?: SubagentProgressUpdate[]
}
export function applySubagentProgress(
previous: ConversationMessageBlock[] | undefined,
event: ProgressEvent
): ConversationMessageBlock[] | undefined {
let blocks = event.progress ?? previous
if (!event.progressUpdates?.length) return blocks
blocks = [...(blocks ?? [])]
for (const update of event.progressUpdates) {
if (update.type === 'reset') {
blocks = [...update.blocks]
continue
}
const id = update.type === 'upsert' ? update.block.id : update.id
const index = blocks.findIndex((block) => block.id === id)
const prior = blocks[index]
const block: ConversationMessageBlock = update.type === 'upsert'
? update.block
: {
id,
type: update.blockType,
content: (
prior?.type === update.blockType ? prior.content : ''
) + update.delta
}
if (index < 0) blocks.push(block)
else blocks[index] = block
}
return blocks
}
export function diffSubagentProgress(
previous: readonly ConversationMessageBlock[],
next: readonly ConversationMessageBlock[]
): SubagentProgressUpdate[] {
if (
previous.length > next.length ||
previous.some((block, index) => block.id !== next[index]?.id)
) {
return [{ type: 'reset', blocks: [...next] }]
}
const updates: SubagentProgressUpdate[] = []
for (let index = 0; index < next.length; index += 1) {
const block = next[index]!
const prior = previous[index]
if (block === prior) continue
if (
(block.type === 'text' || block.type === 'reasoning') &&
prior?.type === block.type &&
block.content.startsWith(prior.content)
) {
const delta = block.content.slice(prior.content.length)
if (delta) {
updates.push({
type: 'append', id: block.id, blockType: block.type, delta
})
}
} else if (!prior || !sameProgressBlock(prior, block)) {
updates.push({ type: 'upsert', block })
}
}
return updates
}
type StoredSubagent = ProgressEvent & {
childTaskId: string
[key: string]: unknown
}
function asSubagent(payload: unknown): StoredSubagent | undefined {
if (
typeof payload !== 'object' || payload === null ||
!('childTaskId' in payload) || typeof payload.childTaskId !== 'string'
) return undefined
return payload as StoredSubagent
}
export function compactSubagentPayload(
payload: unknown,
progress: ReadonlyMap<string, ConversationMessageBlock[]>
): unknown {
const event = asSubagent(payload)
if (!event || (
!Array.isArray(event.progress) && !Array.isArray(event.progressUpdates)
)) return payload
const previous = progress.get(event.childTaskId) ?? []
const compact = { ...event }
delete compact.progress
compact.progressUpdates = diffSubagentProgress(
previous,
applySubagentProgress(previous, event) ?? []
)
return compact
}
export function restoreSubagentPayload(
payload: unknown,
progress: Map<string, ConversationMessageBlock[]>
): unknown {
const event = asSubagent(payload)
if (!event) return payload
const blocks = applySubagentProgress(
progress.get(event.childTaskId), event
)
if (blocks) progress.set(event.childTaskId, blocks)
if (!event.progressUpdates) return payload
const restored = { ...event, progress: blocks ?? [] }
delete restored.progressUpdates
return restored
}
type TaskProgress = {
lastId: number
blocks: Map<string, ConversationMessageBlock[]>
}
/** The conversation keeps the display snapshot; its event history stores changes. */
export class SubagentProgressStorage {
private readonly tasks = new Map<string, TaskProgress>()
constructor(private readonly database: DatabaseSync) {}
serialize(taskId: string, kind: string, payload: unknown): string {
return JSON.stringify(kind === 'subagent'
? compactSubagentPayload(payload, this.current(taskId).blocks)
: payload)
}
inserted(taskId: string, kind: string, id: number, payloadJson: string): void {
if (kind !== 'subagent') {
if (
kind === 'done' || kind === 'error' ||
(kind === 'status' && [
'completed', 'failed', 'cancelled', 'interrupted'
].includes((JSON.parse(payloadJson) as { status?: string }).status ?? ''))
) {
this.tasks.delete(taskId)
}
return
}
const task = this.tasks.get(taskId)
if (!task) return
restoreSubagentPayload(JSON.parse(payloadJson), task.blocks)
task.lastId = id
}
matches(taskId: string, eventId: number, payload: unknown): boolean {
const blocks = new Map<string, ConversationMessageBlock[]>()
const rows = this.database.prepare(
`SELECT id, payload_json FROM task_events
WHERE task_id = ? AND kind = 'subagent' AND id <= ?
ORDER BY id`
).iterate(taskId, eventId)
for (const row of rows) {
const restored = restoreSubagentPayload(
JSON.parse(row.payload_json as string), blocks
)
if (row.id === eventId) return isDeepStrictEqual(restored, payload)
}
return false
}
private current(taskId: string): TaskProgress {
const last = this.database.prepare(
`SELECT id FROM task_events WHERE task_id = ? AND kind = 'subagent'
ORDER BY id DESC LIMIT 1`
).get(taskId) as { id: number } | undefined
let task = this.tasks.get(taskId)
if (task?.lastId === (last?.id ?? 0)) return task
// A rolled-back transaction or reopened task is rebuilt from committed rows.
task = { lastId: last?.id ?? 0, blocks: new Map() }
const rows = this.database.prepare(
`SELECT payload_json FROM task_events
WHERE task_id = ? AND kind = 'subagent' ORDER BY id`
).iterate(taskId)
for (const row of rows) {
restoreSubagentPayload(JSON.parse(row.payload_json as string), task.blocks)
}
this.tasks.delete(taskId)
this.tasks.set(taskId, task)
if (this.tasks.size > 16) {
this.tasks.delete(this.tasks.keys().next().value!)
}
return task
}
}