fix: preserve long run activity and large previews

Long Runtime sessions could silently stop retaining tool and Subagent activity, truncate Run history details, and reject workspace previews over 256 KB. Run history now persists in Main SQLite without the former fixed record or detail caps, surfaces possible loss from legacy caches, and renders long histories in batches.

Local and remote workspace previews now load UTF-8-safe 256 KiB pages on demand instead of rejecting larger text files. The legacy migration, clear-data path, IPC contracts, tests, and feature documentation are updated with the resulting behavior.

Release note: 长时间 Runtime 运行现在会完整保留工具和 Subagent 活动;运行记录迁移到 SQLite,并提示旧版可能已丢失的内容。超过 256KB 的本地及远程文本文件可通过分页面预览继续加载。
This commit is contained in:
mesalogo
2026-09-05 00:31:42 +08:00
parent d180f63abd
commit 9588c4b901
35 changed files with 1316 additions and 546 deletions
+31 -19
View File
@@ -3,6 +3,7 @@ import type { AssistantProject } from './assistant-contracts'
import {
builtInDefaultProjectSeedDescription,
builtInDefaultProjectSeedName,
activityHistorySnapshotSchema,
conversationBranchInputSchema,
conversationMessageSchema,
conversationSubagentActivitySchema,
@@ -33,6 +34,29 @@ const untouchedProject: AssistantProject = {
updatedAt: '2026-08-01T00:00:00.000Z'
}
describe('activity history contracts', () => {
it('accepts more than 500 records and details longer than 4,000 characters', () => {
const records = Array.from({ length: 501 }, (_, index) => ({
id: `activity-${index}`,
conversationId: 'conversation-1',
requestId: 'request-1',
scope: { kind: 'global' as const },
kind: 'tool' as const,
title: `Activity ${index}`,
detail: index === 500 ? 'x'.repeat(4_001) : '',
status: 'completed' as const,
createdAt: index
}))
expect(
activityHistorySnapshotSchema.parse({
records,
legacyHistoryMayBeIncomplete: true
}).records
).toHaveLength(501)
})
})
describe('isUntouchedBuiltInDefaultProject', () => {
it('requires the persistent marker and exact seed text', () => {
expect(isUntouchedBuiltInDefaultProject(untouchedProject)).toBe(true)
@@ -174,14 +198,14 @@ describe('project execution space contracts', () => {
})
describe('conversation activity contracts', () => {
it('accepts long activity runs within the retained display bounds', () => {
const tools = Array.from({ length: 101 }, (_, index) => ({
it('accepts complete long activity runs without a fixed item limit', () => {
const tools = Array.from({ length: 501 }, (_, index) => ({
callId: `call-${index + 1}`,
name: 'read',
state: 'completed' as const,
summary: `Tool ${index + 1}`
}))
const subagents = Array.from({ length: 101 }, (_, index) => ({
const subagents = Array.from({ length: 501 }, (_, index) => ({
childTaskId: `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`,
routingMode: 'native' as const,
actor: {
@@ -203,23 +227,22 @@ describe('conversation activity contracts', () => {
})),
createdAt: 1,
state: 'complete',
displayCaptureTruncated: true,
tools,
subagents
})
).toMatchObject({
tools: expect.arrayContaining([
expect.objectContaining({ callId: 'call-101' })
expect.objectContaining({ callId: 'call-501' })
]),
subagents: expect.arrayContaining([
expect.objectContaining({
childTaskId: '00000000-0000-4000-8000-000000000101'
childTaskId: '00000000-0000-4000-8000-000000000501'
})
])
})
})
it('rejects conversation activity and block arrays beyond display bounds', () => {
it('accepts message block arrays beyond the former display bound', () => {
const baseMessage = {
id: '00000000-0000-4000-8000-000000000999',
role: 'assistant' as const,
@@ -227,17 +250,6 @@ describe('conversation activity contracts', () => {
createdAt: 1,
state: 'complete' as const
}
expect(
conversationMessageSchema.safeParse({
...baseMessage,
tools: Array.from({ length: 501 }, (_, index) => ({
callId: `call-${index + 1}`,
name: 'read',
state: 'completed' as const,
summary: `Tool ${index + 1}`
}))
}).success
).toBe(false)
expect(
conversationMessageSchema.safeParse({
...baseMessage,
@@ -247,7 +259,7 @@ describe('conversation activity contracts', () => {
content: 'x'
}))
}).success
).toBe(false)
).toBe(true)
})
})
+59 -14
View File
@@ -31,6 +31,57 @@ export type ProjectChannel = z.infer<typeof projectChannelSchema>
const projectExecutionSpacePathSchema = z.string().max(4_096)
export const activityRecordSchema = z
.object({
id: z.string().min(1).max(256),
conversationId: z.string().min(1).max(256),
requestId: z.string().min(1).max(256),
callId: z.string().min(1).max(256).optional(),
scope: z.discriminatedUnion('kind', [
z.object({ kind: z.literal('global') }).strict(),
z
.object({
kind: z.literal('project'),
projectId: z.string().min(1).max(256),
projectName: z.string().min(1).max(120)
})
.strict(),
z.object({ kind: z.literal('unavailable') }).strict()
]),
kind: z.enum([
'request',
'tool',
'approval',
'subagent',
'result'
]),
title: z.string().min(1).max(240),
detail: z.string(),
status: z.enum([
'pending',
'running',
'completed',
'failed',
'denied',
'cancelled',
'interrupted'
]),
createdAt: z.number().finite().nonnegative()
})
.strict()
export const activityHistorySnapshotSchema = z
.object({
records: z.array(activityRecordSchema),
legacyHistoryMayBeIncomplete: z.boolean()
})
.strict()
export type ActivityRecord = z.infer<typeof activityRecordSchema>
export type ActivityHistorySnapshot = z.infer<
typeof activityHistorySnapshotSchema
>
export const projectExecutionSpaceSchema = z.discriminatedUnion(
'kind',
[
@@ -182,12 +233,9 @@ export const conversationMessageBlockSchema = z.discriminatedUnion('type', [
.strict()
])
export const maximumConversationRetainedActivities = 500
export const maximumConversationMessageBlocks = 2_000
export const conversationMessageBlocksSchema = z
.array(conversationMessageBlockSchema)
.max(maximumConversationMessageBlocks)
export const conversationMessageBlocksSchema = z.array(
conversationMessageBlockSchema
)
export type ConversationToolActivity = z.infer<
typeof conversationToolActivitySchema
@@ -278,14 +326,8 @@ export const conversationMessageSchema = z
.array(conversationContextCompressionMarkerSchema)
.max(2)
.optional(),
tools: z
.array(conversationToolActivitySchema)
.max(maximumConversationRetainedActivities)
.optional(),
subagents: z
.array(conversationSubagentActivitySchema)
.max(maximumConversationRetainedActivities)
.optional(),
tools: z.array(conversationToolActivitySchema).optional(),
subagents: z.array(conversationSubagentActivitySchema).optional(),
sources: z.array(z.string().max(8_192)).max(100).optional(),
sourceReferences: z
.array(
@@ -559,6 +601,9 @@ export type WorkspaceFilePreview = {
content: string
mimeType: 'text/markdown' | 'text/plain' | 'application/json'
size: number
offsetBytes: number
nextOffsetBytes: number
truncated: boolean
}
export type AssistantTaskStatus =
+13 -2
View File
@@ -41,6 +41,8 @@ import {
type AssistantHeartbeatRun,
type AssistantExpert,
type AssistantTask,
type ActivityHistorySnapshot,
type ActivityRecord,
type TokenUsageSummary,
type ConversationSnapshot,
type ConversationAttachment,
@@ -288,7 +290,8 @@ export const workspaceDirectoryRequestSchema = z
export const workspaceFileRequestSchema = z
.object({
projectId: assistantIdSchema,
path: workspaceRelativePathSchema
path: workspaceRelativePathSchema,
offsetBytes: z.number().int().nonnegative().safe().optional()
})
.strict()
@@ -1888,7 +1891,8 @@ export type DesktopApi = {
) => Promise<WorkspaceDirectoryListing>
readFile: (
projectId: string,
path: string
path: string,
offsetBytes?: number
) => Promise<WorkspaceFilePreview>
openPath: (
projectId: string,
@@ -1903,6 +1907,13 @@ export type DesktopApi = {
status: Extract<AssistantTask['status'], 'completed' | 'cancelled'>
) => Promise<void>
}
activityHistory: {
get: () => Promise<ActivityHistorySnapshot>
replace: (
records: ActivityRecord[],
legacyHistoryMayBeIncomplete: boolean
) => Promise<void>
}
usage: {
getTokenSummary: () => Promise<TokenUsageSummary>
}
+2
View File
@@ -185,6 +185,8 @@ export const ipcChannels = {
workspacePathOpen: 'workspace:path:open',
tasksList: 'tasks:list',
tasksSetStatus: 'tasks:set-status',
activityHistoryGet: 'activity-history:get',
activityHistoryReplace: 'activity-history:replace',
tokenUsageSummary: 'usage:token-summary',
artifactsList: 'artifacts:list',
artifactsGet: 'artifacts:get',