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
+91 -11
View File
@@ -159,11 +159,11 @@ describe('AssistantDatabase', () => {
unchanged.close()
})
it('persists more than 100 tool activities in one message', async () => {
it('persists more than 500 tool activities in one message', async () => {
const database = await createDatabase()
const conversationId = '00000000-0000-4000-8000-000000000901'
const messageId = '00000000-0000-4000-8000-000000000902'
const tools = Array.from({ length: 101 }, (_, index) => ({
const tools = Array.from({ length: 501 }, (_, index) => ({
callId: `call-${index + 1}`,
name: 'read',
state: 'completed' as const,
@@ -192,7 +192,79 @@ describe('AssistantDatabase', () => {
expect(
database.getConversation(conversationId)?.messages[0]?.tools
).toHaveLength(101)
).toHaveLength(501)
database.close()
})
it('persists complete Run history beyond the former Renderer limits', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-activity-history-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
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: `工具调用 ${index}`,
detail: index === 500 ? 'x'.repeat(4_001) : '读取文件',
status: 'completed' as const,
createdAt: index
}))
const initial = new AssistantDatabase(databasePath)
initial.initialize('C:\\Workspace')
initial.replaceActivityHistory({
records,
legacyHistoryMayBeIncomplete: true
})
initial.close()
const reopened = new AssistantDatabase(databasePath)
reopened.initialize('C:\\Workspace')
const history = reopened.getActivityHistory()
expect(history.records).toHaveLength(501)
expect(history.records.at(-1)?.detail).toHaveLength(4_001)
expect(history.legacyHistoryMayBeIncomplete).toBe(true)
reopened.clearAssistantData()
expect(reopened.getActivityHistory()).toEqual({
records: [],
legacyHistoryMayBeIncomplete: false
})
reopened.close()
})
it('keeps the truthful truncation marker on legacy messages', async () => {
const database = await createDatabase()
const conversationId = '00000000-0000-4000-8000-000000000911'
database.saveLocalConversations([
{
header: {
id: conversationId,
title: '旧版截断记录',
updatedAt: 1
},
messages: [
{
id: '00000000-0000-4000-8000-000000000912',
role: 'assistant',
content: '',
createdAt: 1,
state: 'complete',
displayCaptureTruncated: true
}
]
}
])
expect(
database.getConversation(conversationId).messages[0]
?.displayCaptureTruncated
).toBe(true)
database.close()
})
@@ -239,7 +311,7 @@ describe('AssistantDatabase', () => {
database.close()
})
it('migrates existing databases to schema version 32', async () => {
it('migrates existing databases to schema version 33', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-assistant-migration-')
)
@@ -268,7 +340,7 @@ describe('AssistantDatabase', () => {
user_version: number
}
).user_version
).toBe(32)
).toBe(33)
expect(
current
.prepare(
@@ -374,6 +446,14 @@ describe('AssistantDatabase', () => {
)
.get()
).toEqual({ name: 'conversations_branch_source_idx' })
expect(
current
.prepare(
`SELECT name FROM sqlite_master
WHERE type = 'table' AND name = 'activity_history'`
)
.get()
).toEqual({ name: 'activity_history' })
current.close()
})
@@ -430,7 +510,7 @@ describe('AssistantDatabase', () => {
enableForeignKeyConstraints: true
})
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
user_version: 32
user_version: 33
})
expect(
inspected.prepare('SELECT * FROM projects ORDER BY rowid').all()
@@ -528,7 +608,7 @@ describe('AssistantDatabase', () => {
const inspected = new DatabaseSync(databasePath)
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
user_version: 32
user_version: 33
})
expect(
inspected
@@ -605,7 +685,7 @@ describe('AssistantDatabase', () => {
const inspected = new DatabaseSync(databasePath)
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
user_version: 32
user_version: 33
})
expect(
inspected
@@ -855,7 +935,7 @@ describe('AssistantDatabase', () => {
user_version: number
}
).user_version
).toBe(32)
).toBe(33)
expect(
current
.prepare(
@@ -993,7 +1073,7 @@ describe('AssistantDatabase', () => {
const inspected = new DatabaseSync(databasePath)
expect(
inspected.prepare('PRAGMA user_version').get()
).toEqual({ user_version: 32 })
).toEqual({ user_version: 33 })
expect(
inspected
.prepare(
@@ -2690,7 +2770,7 @@ describe('AssistantDatabase', () => {
const inspected = new DatabaseSync(databasePath)
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
user_version: 32
user_version: 33
})
expect(
inspected.prepare('PRAGMA table_info(task_events)').all()
+81 -84
View File
@@ -3,12 +3,11 @@ import { DatabaseSync } from 'node:sqlite'
import {
builtInDefaultProjectSeedDescription,
builtInDefaultProjectSeedName,
activityHistorySnapshotSchema,
assistantIdSchema,
conversationMessageSchema,
conversationSnapshotSchema,
expertCreateSchema,
maximumConversationMessageBlocks,
maximumConversationRetainedActivities,
normalizeInteractiveWorkMode,
persistedProjectExecutionSpaceSchema,
projectCreateSchema,
@@ -24,6 +23,7 @@ import type {
AssistantProject,
AssistantSchedule,
AssistantTask,
ActivityHistorySnapshot,
ConversationQueueItem,
ConversationBranchInput,
ConversationMessageBlock,
@@ -95,7 +95,7 @@ import {
} from '../magic-notes/rich-content'
import { computeNextHeartbeatRun } from './heartbeat-recurrence'
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 32
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 33
export type RemoteTaskEventInput = {
taskId: string
@@ -1123,6 +1123,8 @@ function toConversationSnapshot(
blocks: interrupted
? interruptActiveToolBlocks(metadata.blocks)
: metadata.blocks,
displayCaptureTruncated:
metadata.displayCaptureTruncated,
createdAt:
metadata.createdAt ?? Date.parse(message.created_at),
state: interrupted ? ('error' as const) : message.state,
@@ -1192,9 +1194,6 @@ function appendRecoveredMessageBlock(
)
}
} else {
if (current.length >= maximumConversationMessageBlocks) {
return blocks
}
current.push({
id: randomUUID(),
type,
@@ -1225,9 +1224,6 @@ function upsertRecoveredToolBlock(
: block
)
}
if (blocks.length >= maximumConversationMessageBlocks) {
return blocks
}
return [
...blocks,
{ id: randomUUID(), type: 'tool' as const, tool }
@@ -1267,9 +1263,6 @@ function upsertRecoveredSubagentBlock(
: block
)
}
if (blocks.length >= maximumConversationMessageBlocks) {
return blocks
}
return [
...blocks,
{ id: randomUUID(), type: 'subagent' as const, childTaskId }
@@ -1302,16 +1295,6 @@ function failedToolRepresentsError(
)
}
function canRetainRecoveredActivity(
message: ConversationMessage
): boolean {
return (
(message.tools?.length ?? 0) +
(message.subagents?.length ?? 0) <
maximumConversationRetainedActivities
)
}
function reduceRecoveredAgentEvent(
message: ConversationMessage,
event: RemoteConversationTaskEventInput['event']
@@ -1332,9 +1315,6 @@ function reduceRecoveredAgentEvent(
...message,
content: `${message.content}${delta}`,
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(delta && message.blocks && blocks === message.blocks),
status:
event.delta.length > remaining
? '回答过长,已在本地截断显示'
@@ -1355,9 +1335,6 @@ function reduceRecoveredAgentEvent(
...message,
reasoning: `${reasoning}${delta}`,
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(delta && message.blocks && blocks === message.blocks),
status: undefined
}
} else if (event.type === 'status') {
@@ -1405,25 +1382,14 @@ function reduceRecoveredAgentEvent(
)
if (index >= 0) {
tools[index] = tool
} else if (!canRetainRecoveredActivity(message)) {
next = {
...message,
displayCaptureTruncated: true
}
}
if (index < 0 && next === message) {
} else {
tools.push(tool)
}
if (next === message) {
const blocks = upsertRecoveredToolBlock(message.blocks, tool)
next = {
...message,
tools,
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(message.blocks && blocks === message.blocks)
}
const blocks = upsertRecoveredToolBlock(message.blocks, tool)
next = {
...message,
tools,
blocks
}
} else if (event.type === 'subagent') {
const commonSubagent = {
@@ -1448,49 +1414,25 @@ function reduceRecoveredAgentEvent(
const index = subagents.findIndex(
(candidate) => candidate.childTaskId === event.childTaskId
)
let retainSubagent = true
if (index >= 0) {
subagents[index] = subagent
} else {
const replacesTool = Boolean(
event.runtimeCallId &&
message.tools?.some(
(tool) => tool.callId === event.runtimeCallId
)
)
if (
!replacesTool &&
!canRetainRecoveredActivity(message)
) {
retainSubagent = false
} else {
subagents.push(subagent)
}
subagents.push(subagent)
}
if (!retainSubagent) {
next = {
...message,
displayCaptureTruncated: true
}
} else {
const blocks = upsertRecoveredSubagentBlock(
message.blocks,
event.childTaskId,
event.runtimeCallId
)
next = {
...message,
subagents,
tools: event.runtimeCallId
? message.tools?.filter(
(tool) => tool.callId !== event.runtimeCallId
)
: message.tools,
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(message.blocks && blocks === message.blocks)
}
const blocks = upsertRecoveredSubagentBlock(
message.blocks,
event.childTaskId,
event.runtimeCallId
)
next = {
...message,
subagents,
tools: event.runtimeCallId
? message.tools?.filter(
(tool) => tool.callId !== event.runtimeCallId
)
: message.tools,
blocks
}
} else if (event.type === 'artifact') {
next = {
@@ -2087,6 +2029,11 @@ export class AssistantDatabase {
]) {
database.exec(`DELETE FROM ${table}`)
}
database.exec(
`UPDATE activity_history
SET records_json = '[]', legacy_history_may_be_incomplete = 0
WHERE singleton = 1`
)
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
@@ -4283,6 +4230,40 @@ export class AssistantDatabase {
return rows.map(toTask)
}
getActivityHistory(): ActivityHistorySnapshot {
const row = this.requireDatabase()
.prepare(
`SELECT records_json, legacy_history_may_be_incomplete
FROM activity_history
WHERE singleton = 1`
)
.get() as
| {
records_json: string
legacy_history_may_be_incomplete: number
}
| undefined
return activityHistorySnapshotSchema.parse({
records: row ? JSON.parse(row.records_json) : [],
legacyHistoryMayBeIncomplete:
row?.legacy_history_may_be_incomplete === 1
})
}
replaceActivityHistory(input: ActivityHistorySnapshot): void {
const snapshot = activityHistorySnapshotSchema.parse(input)
this.requireDatabase()
.prepare(
`UPDATE activity_history
SET records_json = ?, legacy_history_may_be_incomplete = ?
WHERE singleton = 1`
)
.run(
JSON.stringify(snapshot.records),
Number(snapshot.legacyHistoryMayBeIncomplete)
)
}
createTask(input: {
id: string
projectId?: string
@@ -9648,6 +9629,22 @@ export class AssistantDatabase {
throw error
}
}
if (version.user_version < 33) {
database.exec(`
BEGIN IMMEDIATE;
CREATE TABLE IF NOT EXISTS activity_history (
singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
records_json TEXT NOT NULL,
legacy_history_may_be_incomplete INTEGER NOT NULL DEFAULT 0
CHECK(legacy_history_may_be_incomplete IN (0, 1))
);
INSERT OR IGNORE INTO activity_history
(singleton, records_json, legacy_history_may_be_incomplete)
VALUES (1, '[]', 0);
PRAGMA user_version = 33;
COMMIT;
`)
}
}
private requireDatabase(): DatabaseSync {
@@ -95,7 +95,10 @@ describe('workspace file browsing', () => {
path: 'remote.md',
name: 'remote.md',
content: '# fake\n',
size: 7
size: 7,
offsetBytes: 0,
bytesRead: 7,
truncated: false
}))
const getChanges = vi.fn(async () => ({
rootPath: '/remote',
@@ -136,7 +139,9 @@ describe('workspace file browsing', () => {
expect(readText).toHaveBeenCalledWith(
expect.objectContaining({
path: 'remote.md',
maximumBytes: 256 * 1024
offsetBytes: 0,
maximumBytes: 256 * 1024,
allowTruncated: true
})
)
})
@@ -171,15 +176,11 @@ describe('workspace file browsing', () => {
})
})
it('rejects traversal, unsupported files, invalid UTF-8, and oversized files', async () => {
it('rejects traversal, unsupported files, and invalid UTF-8', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-files-'))
temporaryDirectories.push(directory)
await writeFile(join(directory, 'image.bin'), Buffer.from([0, 1, 2]))
await writeFile(join(directory, 'invalid.txt'), Buffer.from([0xff]))
await writeFile(
join(directory, 'large.txt'),
Buffer.alloc(256 * 1024 + 1, 97)
)
await expect(
readWorkspaceFile(directory, '../outside.txt')
@@ -190,8 +191,33 @@ describe('workspace file browsing', () => {
await expect(
readWorkspaceFile(directory, 'invalid.txt')
).rejects.toThrow('有效 UTF-8')
await expect(
readWorkspaceFile(directory, 'large.txt')
).rejects.toThrow('超过 256KB')
})
it('reads large UTF-8 files page by page without a total preview limit', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-files-'))
temporaryDirectories.push(directory)
const firstPage = 'a'.repeat(256 * 1024 - 1)
const content = `${firstPage}你\n第二页`
await writeFile(join(directory, 'large.txt'), content)
const first = await readWorkspaceFile(directory, 'large.txt')
expect(first).toMatchObject({
content: firstPage,
offsetBytes: 0,
nextOffsetBytes: 256 * 1024 - 1,
truncated: true
})
const second = await readWorkspaceFile(
directory,
'large.txt',
first.nextOffsetBytes
)
expect(second).toMatchObject({
content: '你\n第二页',
offsetBytes: first.nextOffsetBytes,
truncated: false
})
expect(`${first.content}${second.content}`).toBe(content)
})
})
@@ -10,7 +10,7 @@ import {
} from '../workspace'
const MAX_DIRECTORY_ENTRIES = 500
const MAX_PREVIEW_BYTES = 256 * 1024
const PREVIEW_PAGE_BYTES = 256 * 1024
const previewExtensions = new Set([
'.c',
'.cpp',
@@ -92,6 +92,7 @@ export class WorkspaceChangesService {
async readFile(
inputPath: string,
offsetBytes = 0,
signal?: AbortSignal
): Promise<WorkspaceFilePreview> {
const entry = await this.workspace.stat({
@@ -110,8 +111,9 @@ export class WorkspaceChangesService {
}
const preview = await this.workspace.readText({
path: inputPath,
maximumBytes: MAX_PREVIEW_BYTES,
tooLargeMessage: '工作区文件超过 256KB 预览限制',
offsetBytes,
maximumBytes: PREVIEW_PAGE_BYTES,
allowTruncated: true,
invalidUtf8Message: '工作区文件不是有效 UTF-8 文本',
signal
})
@@ -119,6 +121,9 @@ export class WorkspaceChangesService {
path: preview.path,
name: preview.name,
content: preview.content,
offsetBytes: preview.offsetBytes,
nextOffsetBytes: preview.offsetBytes + preview.bytesRead,
truncated: preview.truncated,
mimeType:
extension === '.md' || extension === '.markdown'
? 'text/markdown'
@@ -177,12 +182,16 @@ export async function listWorkspaceDirectory(
export async function readWorkspaceFile(
workspace: string | WorkspaceAccess,
inputPath: string
inputPath: string,
offsetBytes = 0
): Promise<WorkspaceFilePreview> {
if (typeof workspace !== 'string') {
return new WorkspaceChangesService(workspace).readFile(inputPath)
return new WorkspaceChangesService(workspace).readFile(
inputPath,
offsetBytes
)
}
return withLocalWorkspace(workspace, (access) =>
new WorkspaceChangesService(access).readFile(inputPath)
new WorkspaceChangesService(access).readFile(inputPath, offsetBytes)
)
}
+16 -1
View File
@@ -186,6 +186,7 @@ import {
} from '../shared/magic-notes-contracts'
import {
assistantIdSchema,
activityHistorySnapshotSchema,
conversationBranchInputSchema,
conversationSnapshotsSchema,
localConversationSaveBatchSchema,
@@ -6598,7 +6599,8 @@ export function registerIpcHandlers(
try {
return await readWorkspaceFile(
executionSpace.workspaceAccess,
value.path
value.path,
value.offsetBytes
)
} finally {
await executionSpace.workspaceAccess.dispose()
@@ -6649,6 +6651,19 @@ export function registerIpcHandlers(
parsed.status
)
})
registerHandler(ipcChannels.activityHistoryGet, (event) => {
assertTrustedSender(event, window)
return assistantDatabase.getActivityHistory()
})
registerHandler(
ipcChannels.activityHistoryReplace,
(event, input: unknown) => {
assertTrustedSender(event, window)
assistantDatabase.replaceActivityHistory(
activityHistorySnapshotSchema.parse(input)
)
}
)
registerHandler(ipcChannels.tokenUsageSummary, (event) => {
assertTrustedSender(event, window)
+78
View File
@@ -67,6 +67,84 @@ export async function readBoundedUtf8File(
}
}
export async function readUtf8FileRange(
filePath: string,
offsetBytes: number,
maximumBytes: number,
invalidUtf8Message: string
): Promise<{
content: string
size: number
offsetBytes: number
bytesRead: number
truncated: boolean
}> {
const handle = await open(filePath, 'r')
try {
const metadata = await handle.stat()
if (
!metadata.isFile() ||
!Number.isSafeInteger(offsetBytes) ||
offsetBytes < 0 ||
offsetBytes > metadata.size
) {
throw new Error('工作区读取范围无效')
}
const requestedBytes = Math.min(
maximumBytes,
metadata.size - offsetBytes
)
const data = Buffer.alloc(requestedBytes)
let bytesRead = 0
while (bytesRead < data.length) {
const result = await handle.read(
data,
bytesRead,
data.length - bytesRead,
offsetBytes + bytesRead
)
if (result.bytesRead === 0) {
break
}
bytesRead += result.bytesRead
}
const selected = data.subarray(0, bytesRead)
const reachesEnd = offsetBytes + bytesRead >= metadata.size
let decoded: string | undefined
let decodedBytes = bytesRead
for (
let trailingBytes = 0;
trailingBytes <= (reachesEnd ? 0 : 3);
trailingBytes += 1
) {
decodedBytes = bytesRead - trailingBytes
if (decodedBytes < 0) {
break
}
try {
decoded = new TextDecoder('utf-8', { fatal: true }).decode(
selected.subarray(0, decodedBytes)
)
break
} catch {
// A page may end in the middle of one UTF-8 character.
}
}
if (decoded === undefined) {
throw new Error(invalidUtf8Message)
}
return {
content: decoded,
size: metadata.size,
offsetBytes,
bytesRead: decodedBytes,
truncated: offsetBytes + decodedBytes < metadata.size
}
} finally {
await handle.close()
}
}
export async function readBoundedFile(
filePath: string,
maximumBytes: number,
+29 -1
View File
@@ -23,6 +23,7 @@ import {
isPathInside,
listBoundedDirectoryEntries,
readBoundedUtf8File,
readUtf8FileRange,
resolveExistingWorkspacePath
} from '../workspace-file-access'
import type {
@@ -562,6 +563,30 @@ export class LocalWorkspaceAccess implements WorkspaceAccess {
'工作区读取上限无效'
)
const file = await this.resolveExisting(input.path, 'file')
const offsetBytes = input.offsetBytes ?? 0
if (input.allowTruncated || offsetBytes > 0) {
const preview = await readUtf8FileRange(
file.canonicalPath,
offsetBytes,
maximumBytes,
input.invalidUtf8Message ??
'工作区读取目标不是有效 UTF-8 文本'
)
if (preview.truncated && !input.allowTruncated) {
throw new Error(
input.tooLargeMessage ?? '工作区文本文件超过安全限制'
)
}
if (preview.truncated && preview.bytesRead === 0) {
throw new Error('工作区读取分页无法继续')
}
input.signal?.throwIfAborted()
return {
path: file.path,
name: basename(file.canonicalPath),
...preview
}
}
const preview = await readBoundedUtf8File(
file.canonicalPath,
maximumBytes,
@@ -573,7 +598,10 @@ export class LocalWorkspaceAccess implements WorkspaceAccess {
path: file.path,
name: basename(file.canonicalPath),
content: preview.content,
size: preview.size
size: preview.size,
offsetBytes: 0,
bytesRead: preview.size,
truncated: false
}
}
@@ -168,7 +168,10 @@ describe('RemoteWorkspaceAccess', () => {
path: 'README.md',
name: 'README.md',
content: 'hello',
size: 5
size: 5,
offsetBytes: 0,
bytesRead: 5,
truncated: false
})
await expect(
access.search({
@@ -365,6 +368,70 @@ describe('RemoteWorkspaceAccess', () => {
expect(transport.acquireLease).not.toHaveBeenCalled()
})
it('returns remote file pages and keeps UTF-8 boundaries intact', async () => {
const invalidBoundary = Object.assign(
new Error('Workspace file is not valid UTF-8'),
{ data: { code: 'invalid-utf8' } }
)
const readWorkspaceText = vi
.fn()
.mockRejectedValueOnce(invalidBoundary)
.mockResolvedValueOnce({
relativePath: 'large.txt',
content: '你a',
offsetBytes: 0,
bytesRead: 4,
totalBytes: 7,
digest,
truncated: true
})
.mockResolvedValueOnce({
relativePath: 'large.txt',
content: '好',
offsetBytes: 4,
bytesRead: 3,
totalBytes: 7,
digest,
truncated: false
})
const lease = createLease({ readWorkspaceText })
const { access } = createAccess(lease)
const first = await access.readText({
path: 'large.txt',
maximumBytes: 5,
allowTruncated: true
})
const second = await access.readText({
path: 'large.txt',
offsetBytes: first.offsetBytes + first.bytesRead,
maximumBytes: 5,
allowTruncated: true
})
expect(first).toMatchObject({
content: '你a',
offsetBytes: 0,
bytesRead: 4,
truncated: true
})
expect(second).toMatchObject({
content: '好',
offsetBytes: 4,
truncated: false
})
expect(
readWorkspaceText.mock.calls.map(([request]) => ({
offsetBytes: request.offsetBytes,
maximumBytes: request.maximumBytes
}))
).toEqual([
{ offsetBytes: 0, maximumBytes: 5 },
{ offsetBytes: 0, maximumBytes: 4 },
{ offsetBytes: 4, maximumBytes: 5 }
])
})
it('retries one inconsistent Git snapshot and maps status and diff', async () => {
const getGitStatus = vi
.fn()
+65 -13
View File
@@ -149,6 +149,20 @@ function boundedInteger(
return resolved
}
function isInvalidUtf8RpcError(error: unknown): boolean {
if (
!error ||
typeof error !== 'object' ||
!('data' in error) ||
!error.data ||
typeof error.data !== 'object' ||
!('code' in error.data)
) {
return false
}
return error.data.code === 'invalid-utf8'
}
function assertTransportBinding(
expected: RemoteWorkspaceProjectBinding,
actual: RemoteWorkspaceTransportBinding
@@ -419,16 +433,43 @@ export class RemoteWorkspaceAccess implements WorkspaceAccess {
'工作区读取上限无效'
)
const opened = await this.open(input.signal)
const request = remoteWorkspaceReadTextRequestSchema.parse({
...this.reference(opened),
relativePath: input.path,
offsetBytes: 0,
maximumBytes
})
const rawResult = await opened.lease.readWorkspaceText(
request,
input.signal
)
const offsetBytes = input.offsetBytes ?? 0
let rawResult: RemoteWorkspaceReadTextResult | undefined
let boundaryError: unknown
const maximumTrailingBytes = input.allowTruncated
? Math.min(3, maximumBytes - 1)
: 0
for (
let trailingBytes = 0;
trailingBytes <= maximumTrailingBytes;
trailingBytes += 1
) {
const request = remoteWorkspaceReadTextRequestSchema.parse({
...this.reference(opened),
relativePath: input.path,
offsetBytes,
maximumBytes: maximumBytes - trailingBytes
})
try {
rawResult = await opened.lease.readWorkspaceText(
request,
input.signal
)
break
} catch (error) {
if (
!input.allowTruncated ||
trailingBytes === maximumTrailingBytes ||
!isInvalidUtf8RpcError(error)
) {
throw error
}
boundaryError = error
}
}
if (!rawResult) {
throw boundaryError
}
let result: RemoteWorkspaceReadTextResult
try {
result = remoteWorkspaceReadTextResultSchema.parse(rawResult)
@@ -444,21 +485,32 @@ export class RemoteWorkspaceAccess implements WorkspaceAccess {
}
if (
result.relativePath !== input.path ||
result.offsetBytes !== 0
result.offsetBytes !== offsetBytes ||
result.offsetBytes + result.bytesRead > result.totalBytes ||
(!result.truncated &&
result.offsetBytes + result.bytesRead !== result.totalBytes) ||
(result.truncated &&
result.offsetBytes + result.bytesRead >= result.totalBytes)
) {
throw new Error('远程工作区返回了不匹配的文件')
}
if (result.truncated || result.bytesRead !== result.totalBytes) {
if (result.truncated && !input.allowTruncated) {
throw new Error(
input.tooLargeMessage ?? '工作区文本文件超过安全限制'
)
}
if (result.truncated && result.bytesRead === 0) {
throw new Error('远程工作区读取分页无法继续')
}
const name = input.path.split('/').at(-1) ?? input.path
return {
path: input.path,
name,
content: result.content,
size: result.totalBytes
size: result.totalBytes,
offsetBytes: result.offsetBytes,
bytesRead: result.bytesRead,
truncated: result.truncated
}
}
+5
View File
@@ -39,7 +39,9 @@ export type WorkspaceEntry = WorkspaceDirectoryEntry & {
}
export type WorkspaceReadInput = WorkspacePathInput & {
offsetBytes?: number
maximumBytes?: number
allowTruncated?: boolean
tooLargeMessage?: string
invalidUtf8Message?: string
}
@@ -49,6 +51,9 @@ export type FilePreview = {
name: string
content: string
size: number
offsetBytes: number
bytesRead: number
truncated: boolean
}
export type WorkspaceWriteInput = WorkspacePathInput & {
+24 -2
View File
@@ -49,6 +49,8 @@ import type {
AssistantHeartbeatRun,
AssistantExpert,
AssistantTask,
ActivityHistorySnapshot,
ActivityRecord,
TokenUsageSummary,
ConversationBranchInput,
ConversationSnapshot,
@@ -1096,10 +1098,15 @@ const desktopApi: DesktopApi = {
projectId,
path
}) as Promise<WorkspaceDirectoryListing>,
readFile: (projectId: string, path: string) =>
readFile: (
projectId: string,
path: string,
offsetBytes?: number
) =>
ipcRenderer.invoke(ipcChannels.workspaceFileRead, {
projectId,
path
path,
offsetBytes
}) as Promise<WorkspaceFilePreview>,
openPath: async (
projectId: string,
@@ -1126,6 +1133,21 @@ const desktopApi: DesktopApi = {
})
}
},
activityHistory: {
get: () =>
ipcRenderer.invoke(
ipcChannels.activityHistoryGet
) as Promise<ActivityHistorySnapshot>,
replace: async (
records: ActivityRecord[],
legacyHistoryMayBeIncomplete: boolean
) => {
await ipcRenderer.invoke(ipcChannels.activityHistoryReplace, {
records,
legacyHistoryMayBeIncomplete
})
}
},
usage: {
getTokenSummary: () =>
ipcRenderer.invoke(
+27 -6
View File
@@ -12,10 +12,7 @@ import {
builtInDefaultProjectSeedName
} from '../../shared/assistant-contracts'
import { ActivityPanel } from './ActivityPanel'
import {
MAX_ACTIVITY_RECORDS,
type ActivityRecord
} from './activity-store'
import { type ActivityRecord } from './activity-store'
import i18n from './i18n'
function makeRecord(
@@ -642,9 +639,9 @@ describe('ActivityPanel', () => {
expect(within(detail).getByText('项目:项目甲')).toBeInTheDocument()
})
it('never renders more than 500 records', () => {
it('reveals retained records beyond the initial render batch', () => {
const records = Array.from(
{ length: MAX_ACTIVITY_RECORDS + 1 },
{ length: 501 },
(_, index) => makeRecord(index)
)
render(
@@ -658,6 +655,30 @@ describe('ActivityPanel', () => {
expect(screen.getByText('活动 499')).toBeInTheDocument()
expect(screen.queryByText('活动 500')).not.toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', {
name: '继续显示(剩余 1 条)'
})
)
expect(screen.getByText('活动 500')).toBeInTheDocument()
})
it('warns when the migrated legacy history may already be incomplete', () => {
render(
<ActivityPanel
legacyHistoryMayBeIncomplete
onClear={vi.fn()}
onOpenConversation={vi.fn()}
records={[makeRecord(1)]}
tokenUsage={makeTokenUsage()}
/>
)
expect(
screen.getByText(
'旧版本曾限制本机运行记录的数量和详情长度,因此更早的记录或部分详情可能已经丢失。新记录会完整保存。'
)
).toHaveAttribute('role', 'status')
})
it('shows totals without double-counting cache tokens', () => {
+49 -20
View File
@@ -6,10 +6,7 @@ import type {
TokenUsageSummary
} from '../../shared/assistant-contracts'
import { isUntouchedBuiltInDefaultProject } from '../../shared/assistant-contracts'
import {
MAX_ACTIVITY_RECORDS,
type ActivityRecord
} from './activity-store'
import { type ActivityRecord } from './activity-store'
import {
getTokenUsageTotals,
groupTokenUsage,
@@ -35,7 +32,10 @@ type ActivityActorKind =
| 'tool'
| 'approval'
const activityRenderBatchSize = 500
export type ActivityPanelProps = {
legacyHistoryMayBeIncomplete?: boolean
projects?: readonly AssistantProject[]
records: readonly ActivityRecord[]
tokenUsage: TokenUsageSummary
@@ -240,6 +240,7 @@ function groupActivityRecordsByProject(
}
export function ActivityPanel({
legacyHistoryMayBeIncomplete = false,
projects = [],
records,
tokenUsage,
@@ -256,6 +257,9 @@ export function ActivityPanel({
const [confirmingClear, setConfirmingClear] = useState(false)
const [selectedTimelineRecordId, setSelectedTimelineRecordId] =
useState<string>()
const [visibleRecordCount, setVisibleRecordCount] = useState(
activityRenderBatchSize
)
const dateTimeFormatter = useMemo(
() =>
new Intl.DateTimeFormat(i18n.resolvedLanguage || 'zh-CN', {
@@ -378,13 +382,13 @@ export function ActivityPanel({
}),
[projectDisplayNames, tokenUsage]
)
const visibleRecords = useMemo(
() => displayRecords.slice(0, MAX_ACTIVITY_RECORDS),
[displayRecords]
const matchingRecords = useMemo(
() => displayRecords.filter((record) => matchesFilter(record, filter)),
[displayRecords, filter]
)
const filteredRecords = useMemo(
() => visibleRecords.filter((record) => matchesFilter(record, filter)),
[filter, visibleRecords]
() => matchingRecords.slice(0, visibleRecordCount),
[matchingRecords, visibleRecordCount]
)
const projectGroups = useMemo(
() => groupActivityRecordsByProject(filteredRecords, displayRecords),
@@ -418,8 +422,8 @@ export function ActivityPanel({
() => getConversationTitles(displayRecords),
[displayRecords]
)
const activeCount = visibleRecords.filter(isActive).length
const failedCount = visibleRecords.filter(isFailed).length
const activeCount = displayRecords.filter(isActive).length
const failedCount = displayRecords.filter(isFailed).length
const filters: ReadonlyArray<{
value: ActivityFilter
label: string
@@ -427,8 +431,8 @@ export function ActivityPanel({
{
value: 'all',
label: t('filters.all', {
count: visibleRecords.length,
formattedCount: formatCount(visibleRecords.length)
count: displayRecords.length,
formattedCount: formatCount(displayRecords.length)
})
},
{
@@ -620,19 +624,19 @@ export function ActivityPanel({
/>
<DestructiveConfirmActions
confirmAriaLabel={t('clear.confirmAriaLabel', {
count: visibleRecords.length,
formattedCount: formatCount(visibleRecords.length)
count: displayRecords.length,
formattedCount: formatCount(displayRecords.length)
})}
confirmLabel={t('clear.confirmLabel', {
count: visibleRecords.length,
formattedCount: formatCount(visibleRecords.length)
count: displayRecords.length,
formattedCount: formatCount(displayRecords.length)
})}
confirming={confirmingClear}
disabled={!confirmingClear && visibleRecords.length === 0}
disabled={!confirmingClear && displayRecords.length === 0}
icon={<Trash2 aria-hidden="true" size={15} />}
message={t('clear.message', {
count: visibleRecords.length,
formattedCount: formatCount(visibleRecords.length)
count: displayRecords.length,
formattedCount: formatCount(displayRecords.length)
})}
onCancel={() => setConfirmingClear(false)}
onConfirm={() => {
@@ -667,6 +671,24 @@ export function ActivityPanel({
}
/>
)
const loadMoreRecords =
filteredRecords.length < matchingRecords.length ? (
<button
className="secondary-button activity-panel__load-more"
onClick={() =>
setVisibleRecordCount(
(current) => current + activityRenderBatchSize
)
}
type="button"
>
{t('records.loadMore', {
remaining: formatCount(
matchingRecords.length - filteredRecords.length
)
})}
</button>
) : null
return (
<section
@@ -680,6 +702,11 @@ export function ActivityPanel({
scope={{ kind: 'all-projects' }}
title={t('header.title')}
/>
{legacyHistoryMayBeIncomplete && (
<p className="activity-panel__legacy-warning" role="status">
{t('records.legacyHistoryMayBeIncomplete')}
</p>
)}
<PageTabs
ariaLabel={t('tabs.ariaLabel')}
@@ -781,6 +808,7 @@ export function ActivityPanel({
</div>
</section>
))}
{loadMoreRecords}
</div>
)}
</div>
@@ -957,6 +985,7 @@ export function ActivityPanel({
{renderRecordCard(selectedTimelineRecord, true)}
</aside>
)}
{loadMoreRecords}
</div>
)}
</div>
+107 -16
View File
@@ -112,7 +112,7 @@ vi.mock('./TerminalPanel', () => ({
}))
import App from './App'
import { loadActivityRecords } from './activity-store'
import { ACTIVITY_STORAGE_KEY } from './activity-store'
import { changeUiLocale } from './i18n'
import { UiLocaleProvider } from './i18n/UiLocaleProvider'
@@ -541,7 +541,10 @@ const api: DesktopApi = {
name: path.split('/').at(-1) ?? path,
content: '',
mimeType: 'text/plain' as const,
size: 0
size: 0,
offsetBytes: 0,
nextOffsetBytes: 0,
truncated: false
})),
openPath: vi.fn(async () => {})
},
@@ -549,6 +552,13 @@ const api: DesktopApi = {
list: vi.fn(async () => []),
setStatus: vi.fn(async () => {})
},
activityHistory: {
get: vi.fn(async () => ({
records: [],
legacyHistoryMayBeIncomplete: false
})),
replace: vi.fn(async () => {})
},
usage: {
getTokenSummary: vi.fn(async () => ({
totals: {
@@ -1137,6 +1147,15 @@ describe('App', () => {
}
})
vi.mocked(api.tasks.list).mockReset().mockResolvedValue([])
vi.mocked(api.activityHistory.get)
.mockReset()
.mockResolvedValue({
records: [],
legacyHistoryMayBeIncomplete: false
})
vi.mocked(api.activityHistory.replace)
.mockReset()
.mockResolvedValue()
vi.mocked(api.schedules.list).mockReset().mockResolvedValue([])
api.channels = undefined
api.updates = undefined
@@ -3694,7 +3713,7 @@ describe('App', () => {
expect(screen.getByText('项目:默认项目')).toHaveClass('scope-badge')
})
it('keeps a bounded display copy without stopping a long tool chain', async () => {
it('keeps every activity detail in a long tool chain', async () => {
render(<App />)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
@@ -3713,21 +3732,38 @@ describe('App', () => {
requestId: request.requestId,
type: 'tool',
callId: `call-${index + 1}`,
name: 'read',
name: index === 500 ? `read${'长'.repeat(300)}` : 'read',
state: 'completed',
summary: `OpenCode 工具:read ${index + 1}`
summary:
index === 500
? `OpenCode 工具:read 501 ${'详情'.repeat(2_100)}`
: `OpenCode 工具:read ${index + 1}`
})
}
})
expect(
screen.getByRole('region', { name: '工具执行,共 500 项' })
screen.getByRole('region', { name: '工具执行,共 501 项' })
).toBeInTheDocument()
expect(
screen.getByText(
screen.queryByText(
'活动记录过多,后续详情未在本地保留;Runtime 任务仍继续执行'
)
).toBeInTheDocument()
).not.toBeInTheDocument()
await waitFor(
() => {
const saved =
vi.mocked(api.activityHistory.replace).mock.calls.at(-1)?.[0] ??
[]
expect(
saved.filter((record) => record.kind === 'tool')
).toHaveLength(501)
const lastTool = saved.find((record) => record.callId === 'call-501')
expect(lastTool?.detail.length).toBeGreaterThan(4_000)
expect(lastTool?.title).toHaveLength(240)
},
{ timeout: 3_000 }
)
})
it('keeps adjacent streamed text blocks exact in StrictMode', async () => {
@@ -5593,7 +5629,10 @@ describe('App', () => {
name: 'README.md',
content: '# 工作区说明',
mimeType: 'text/markdown',
size: 19
size: 19,
offsetBytes: 0,
nextOffsetBytes: 19,
truncated: false
})
render(<App />)
@@ -5614,7 +5653,8 @@ describe('App', () => {
).not.toBeInTheDocument()
expect(api.workspace.readFile).toHaveBeenCalledWith(
projectId,
'README.md'
'README.md',
0
)
fireEvent.click(
screen.getByRole('button', { name: '返回工作区' })
@@ -10645,11 +10685,14 @@ describe('App', () => {
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() =>
expect(
loadActivityRecords().find(
(record) =>
record.kind === 'request' &&
record.title === '记录项目范围'
)?.scope
vi
.mocked(api.activityHistory.replace)
.mock.calls.at(-1)?.[0]
.find(
(record) =>
record.kind === 'request' &&
record.title === '记录项目范围'
)?.scope
).toEqual({
kind: 'project',
projectId,
@@ -10658,6 +10701,50 @@ describe('App', () => {
)
})
it('migrates legacy activity and keeps its possible-loss warning visible', async () => {
localStorage.setItem(
ACTIVITY_STORAGE_KEY,
JSON.stringify([
{
id: 'legacy-activity',
conversationId: 'legacy-conversation',
requestId: 'legacy-request',
scope: { kind: 'global' },
kind: 'tool',
title: '旧版工具记录',
detail: 'x'.repeat(4_000),
status: 'completed',
createdAt: 1
}
])
)
render(<App />)
await screen.findByText('项目:默认项目')
await waitFor(() => {
expect(api.activityHistory.replace).toHaveBeenCalledWith(
[
expect.objectContaining({
id: 'legacy-activity',
detail: 'x'.repeat(4_000)
})
],
true
)
expect(localStorage.getItem(ACTIVITY_STORAGE_KEY)).toBeNull()
})
fireEvent.click(screen.getByRole('button', { name: '运行记录' }))
expect(
await screen.findByText(
'旧版本曾限制本机运行记录的数量和详情长度,因此更早的记录或部分详情可能已经丢失。新记录会完整保存。',
{},
{ timeout: 3_000 }
)
).toBeInTheDocument()
})
it('marks the current primary navigation page and hides decorative icons', async () => {
render(<App />)
@@ -10896,7 +10983,11 @@ describe('App', () => {
fireEvent.click(magicNotesEntry)
expect(
await screen.findByRole('heading', { name: '魔法笔记' })
await screen.findByRole(
'heading',
{ name: '魔法笔记' },
{ timeout: 3000 }
)
).toBeInTheDocument()
expect(screen.getByText('全局')).toBeInTheDocument()
expect(
+138 -84
View File
@@ -94,6 +94,7 @@ import {
getRuntimeSelectionForProvider
} from './runtime-selection'
import type {
ActivityHistorySnapshot,
AssistantProject,
AssistantArtifact,
AssistantMemory,
@@ -128,8 +129,6 @@ import {
conversationMessageBlocksSchema,
conversationSubagentActivitySchema,
interactiveWorkModes,
maximumConversationMessageBlocks,
maximumConversationRetainedActivities,
normalizeInteractiveWorkMode,
projectChannelLabels
} from '../../shared/assistant-contracts'
@@ -141,9 +140,10 @@ import {
type ToolActivity
} from './ChatTimeline'
import {
loadActivityRecords,
clearLegacyActivityHistory,
loadLegacyActivityHistory,
mergeActivityRecords,
reconcileActivityRecords,
saveActivityRecords,
upsertActivityRecord,
type ActivityRecord
} from './activity-store'
@@ -638,9 +638,6 @@ function appendMessageContentBlock(
}
return current
}
if (current.length >= maximumConversationMessageBlocks) {
return blocks
}
current.push({
id: crypto.randomUUID(),
type,
@@ -670,9 +667,6 @@ function upsertMessageToolBlock(
: block
)
}
if (blocks.length >= maximumConversationMessageBlocks) {
return blocks
}
return [
...blocks,
{
@@ -718,9 +712,6 @@ function upsertMessageSubagentBlock(
: block
)
}
if (blocks.length >= maximumConversationMessageBlocks) {
return blocks
}
return [
...blocks,
{
@@ -749,14 +740,6 @@ function terminalizeMessageToolBlocks(
)
}
function canRetainMessageActivity(message: Message): boolean {
return (
(message.tools?.length ?? 0) +
(message.subagents?.length ?? 0) <
maximumConversationRetainedActivities
)
}
function isErrorRepresentedByFailedTool(
tools: ToolActivity[] | undefined,
errorMessage: string
@@ -2688,10 +2671,19 @@ function App(): React.JSX.Element {
const [knowledgeScopeOpen, setKnowledgeScopeOpen] = useState(false)
const knowledgeScopeTriggerRef = useRef<HTMLButtonElement>(null)
const knowledgeScopePopoverRef = useRef<HTMLDivElement>(null)
const [legacyActivityHistory] = useState(loadLegacyActivityHistory)
const [activityRecords, setActivityRecords] = useState<ActivityRecord[]>(
loadActivityRecords
legacyActivityHistory.records
)
const [
legacyActivityHistoryMayBeIncomplete,
setLegacyActivityHistoryMayBeIncomplete
] = useState(legacyActivityHistory.historyMayBeIncomplete)
const [activityHistoryReady, setActivityHistoryReady] = useState(false)
const activityRecordsRef = useRef(activityRecords)
const legacyActivityHistoryMayBeIncompleteRef = useRef(
legacyActivityHistoryMayBeIncomplete
)
const activeRuns = useRef(new Map<string, ActiveRun>())
const preparingConversations = useRef(new Set<string>())
const [activeConversationIds, setActiveConversationIds] = useState<
@@ -3940,6 +3932,7 @@ function App(): React.JSX.Element {
setActivityRecords((current) =>
upsertActivityRecord(current, {
...record,
title: record.title.slice(0, 240),
scope,
id: crypto.randomUUID(),
createdAt: Date.now()
@@ -3961,7 +3954,7 @@ function App(): React.JSX.Element {
? {
...record,
status,
detail: detail?.slice(0, 4_000) ?? record.detail
detail: detail ?? record.detail
}
: record
)
@@ -4398,13 +4391,6 @@ function App(): React.JSX.Element {
...message,
content: `${message.content}${acceptedDelta}`,
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(
acceptedDelta &&
message.blocks &&
blocks === message.blocks
),
status:
event.delta.length > remaining
? tRef.current('chat.status.responseTruncated')
@@ -4430,14 +4416,7 @@ function App(): React.JSX.Element {
...message,
reasoning: `${currentReasoning}${acceptedDelta}`,
status: undefined,
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(
acceptedDelta &&
message.blocks &&
blocks === message.blocks
)
blocks
}
})
} else if (event.type === 'context-metrics') {
@@ -4548,8 +4527,7 @@ function App(): React.JSX.Element {
title: event.name,
detail: [event.summary, event.error]
.filter(Boolean)
.join('\n')
.slice(0, 4_000),
.join('\n'),
status:
event.state === 'pending'
? 'pending'
@@ -4575,23 +4553,14 @@ function App(): React.JSX.Element {
}
if (index >= 0) {
tools[index] = tool
} else if (!canRetainMessageActivity(message)) {
return {
...message,
displayCaptureTruncated: true
}
}
if (index < 0) {
} else {
tools.push(tool)
}
const blocks = upsertMessageToolBlock(message.blocks, tool)
return {
...message,
tools,
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(message.blocks && blocks === message.blocks)
blocks
}
})
} else if (event.type === 'subagent') {
@@ -4688,8 +4657,7 @@ function App(): React.JSX.Element {
event.error
]
.filter(Boolean)
.join(' · ')
.slice(0, 4_000),
.join(' · '),
status:
event.state === 'queued'
? 'pending'
@@ -4722,21 +4690,6 @@ function App(): React.JSX.Element {
if (index >= 0) {
subagents[index] = subagent
} else {
const replacesTool = Boolean(
event.runtimeCallId &&
message.tools?.some(
(tool) => tool.callId === event.runtimeCallId
)
)
if (
!replacesTool &&
!canRetainMessageActivity(message)
) {
return {
...message,
displayCaptureTruncated: true
}
}
subagents.push(subagent)
}
const runtimeCallId = event.runtimeCallId
@@ -4753,10 +4706,7 @@ function App(): React.JSX.Element {
(tool) => tool.callId !== runtimeCallId
)
: message.tools,
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(message.blocks && blocks === message.blocks)
blocks
}
})
} else if (event.type === 'approval') {
@@ -4765,7 +4715,7 @@ function App(): React.JSX.Element {
requestId: event.requestId,
kind: 'approval',
title: event.title,
detail: event.description.slice(0, 4_000),
detail: event.description,
status: 'pending'
})
updateMessage(run.conversationId, run.messageId, (message) => ({
@@ -4879,7 +4829,7 @@ function App(): React.JSX.Element {
event.type === 'done'
? incompleteActivityDetail
: event.message
}`.slice(0, 4_000)
}`
}
: record
)
@@ -4894,7 +4844,7 @@ function App(): React.JSX.Element {
: tRef.current('chat.status.taskCompleted'),
detail:
event.type === 'error'
? event.message.slice(0, 4_000)
? event.message
: tRef.current('chat.status.runtimeCompleted'),
status: terminalStatus
})
@@ -5109,10 +5059,28 @@ function App(): React.JSX.Element {
persistLocalConversationChanges
])
const persistActivityHistory = useCallback(async (): Promise<void> => {
if (!activityHistoryReady) {
return
}
try {
await window.goodbuddy.activityHistory.replace(
activityRecordsRef.current,
legacyActivityHistoryMayBeIncompleteRef.current
)
} catch {
notify({
tone: 'error',
message: tRef.current('notices.activityHistoryPersistenceFailed'),
dedupeKey: 'activity-history-persistence'
})
}
}, [activityHistoryReady])
useEffect(
() =>
window.goodbuddy.app.onBeforeQuit(async () => {
saveActivityRecords(activityRecordsRef.current)
await persistActivityHistory()
if (!conversationStoreReady) {
return
}
@@ -5120,7 +5088,11 @@ function App(): React.JSX.Element {
persistLocalConversationChanges()
await conversationPersistenceQueueRef.current
}),
[conversationStoreReady, persistLocalConversationChanges]
[
conversationStoreReady,
persistActivityHistory,
persistLocalConversationChanges
]
)
useEffect(() => {
@@ -5437,19 +5409,88 @@ function App(): React.JSX.Element {
}
}, [])
useEffect(() => {
let active = true
void (async () => {
let snapshot: ActivityHistorySnapshot
try {
snapshot = await window.goodbuddy.activityHistory.get()
} catch {
if (active) {
notify({
tone: 'error',
message: tRef.current('notices.activityHistoryReadFailed'),
dedupeKey: 'activity-history-read'
})
}
return
}
if (!active) {
return
}
const legacyHistoryMayBeIncomplete =
legacyActivityHistory.historyMayBeIncomplete ||
snapshot.legacyHistoryMayBeIncomplete
if (
legacyActivityHistory.records.length > 0 ||
legacyActivityHistory.historyMayBeIncomplete
) {
try {
await window.goodbuddy.activityHistory.replace(
mergeActivityRecords(
legacyActivityHistory.records,
snapshot.records
),
legacyHistoryMayBeIncomplete
)
clearLegacyActivityHistory()
} catch {
notify({
tone: 'error',
message: tRef.current(
'notices.activityHistoryPersistenceFailed'
),
dedupeKey: 'activity-history-persistence'
})
}
if (!active) {
return
}
}
setActivityRecords((current) =>
mergeActivityRecords(current, snapshot.records)
)
setLegacyActivityHistoryMayBeIncomplete(legacyHistoryMayBeIncomplete)
setActivityHistoryReady(true)
})()
return () => {
active = false
}
}, [legacyActivityHistory])
useEffect(() => {
activityRecordsRef.current = activityRecords
legacyActivityHistoryMayBeIncompleteRef.current =
legacyActivityHistoryMayBeIncomplete
if (!activityHistoryReady) {
return
}
const timeout = window.setTimeout(() => {
saveActivityRecords(activityRecords)
void persistActivityHistory()
}, 250)
return () => window.clearTimeout(timeout)
}, [activityRecords])
}, [
activityHistoryReady,
activityRecords,
legacyActivityHistoryMayBeIncomplete,
persistActivityHistory
])
useEffect(
() => () => {
saveActivityRecords(activityRecordsRef.current)
void persistActivityHistory()
},
[]
[persistActivityHistory]
)
const resumeProjectConversationQueues = useCallback(
@@ -5769,11 +5810,15 @@ function App(): React.JSX.Element {
)
const loadWorkspaceFile = useCallback(
async (path: string) => {
async (path: string, offsetBytes = 0) => {
if (!activeProjectId) {
throw new Error(tRef.current('notices.selectProject'))
}
return window.goodbuddy.workspace.readFile(activeProjectId, path)
return window.goodbuddy.workspace.readFile(
activeProjectId,
path,
offsetBytes
)
},
[activeProjectId]
)
@@ -8173,6 +8218,8 @@ function App(): React.JSX.Element {
persistedLocalConversationsRef.current.clear()
setConversations([conversation])
setActiveId(conversation.id)
legacyActivityHistoryMayBeIncompleteRef.current = false
setLegacyActivityHistoryMayBeIncomplete(false)
setActivityRecords([])
setAssistantTasks([])
setTokenUsage(emptyTokenUsage)
@@ -10871,7 +10918,14 @@ function App(): React.JSX.Element {
}
>
<ActivityPanel
onClear={() => setActivityRecords([])}
legacyHistoryMayBeIncomplete={
legacyActivityHistoryMayBeIncomplete
}
onClear={() => {
legacyActivityHistoryMayBeIncompleteRef.current = false
setLegacyActivityHistoryMayBeIncomplete(false)
setActivityRecords([])
}}
onOpenConversation={openActivityConversation}
projects={projects}
records={activityRecords}
@@ -55,7 +55,8 @@ function renderSidebar({
}>
onLoadArtifact?: (artifactId: string) => Promise<void>
onLoadWorkspaceFile?: (
path: string
path: string,
offsetBytes?: number
) => Promise<WorkspaceFilePreview>
workspaceProjectId?: string
restoreFocusRef?: { current: HTMLElement | null }
@@ -330,7 +331,10 @@ describe('RightAssistantSidebar resizing', () => {
name: 'README.md',
content: 'Recovered preview',
mimeType: 'text/plain',
size: 17
size: 17,
offsetBytes: 0,
nextOffsetBytes: 17,
truncated: false
})
renderSidebar({
tab: 'workspace',
@@ -372,7 +376,70 @@ describe('RightAssistantSidebar resizing', () => {
expect(await screen.findByText('Recovered preview'))
.toBeInTheDocument()
expect(onLoadWorkspaceFile).toHaveBeenCalledTimes(2)
expect(onLoadWorkspaceFile).toHaveBeenNthCalledWith(2, 'README.md')
expect(onLoadWorkspaceFile).toHaveBeenNthCalledWith(
2,
'README.md',
0
)
})
it('loads and appends the rest of a large workspace file', async () => {
const onLoadWorkspaceFile = vi
.fn()
.mockResolvedValueOnce({
path: 'large.txt',
name: 'large.txt',
content: 'first page\n',
mimeType: 'text/plain',
size: 30,
offsetBytes: 0,
nextOffsetBytes: 11,
truncated: true
})
.mockResolvedValueOnce({
path: 'large.txt',
name: 'large.txt',
content: 'second page',
mimeType: 'text/plain',
size: 30,
offsetBytes: 11,
nextOffsetBytes: 30,
truncated: false
})
renderSidebar({
tab: 'workspace',
workspaceProjectId: 'project-1',
onListWorkspaceDirectory: vi.fn(async (path: string) => ({
path,
entries: [
{
name: 'large.txt',
path: 'large.txt',
type: 'file' as const
}
],
truncated: false
})),
onLoadWorkspaceFile
})
fireEvent.click(
await screen.findByRole('button', { name: 'large.txt' })
)
expect(await screen.findByText('first page')).toBeInTheDocument()
expect(screen.getByText('已加载 11 / 30 字节')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '继续加载' }))
expect(await screen.findByText(/first page\s+second page/u))
.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '继续加载' }))
.not.toBeInTheDocument()
expect(onLoadWorkspaceFile).toHaveBeenNthCalledWith(
2,
'large.txt',
11
)
})
it('previews a result without switching to a separate tab', () => {
+109 -11
View File
@@ -120,7 +120,10 @@ type RightAssistantSidebarProps = {
onListWorkspaceDirectory: (
path: string
) => Promise<WorkspaceDirectoryListing>
onLoadWorkspaceFile: (path: string) => Promise<WorkspaceFilePreview>
onLoadWorkspaceFile: (
path: string,
offsetBytes?: number
) => Promise<WorkspaceFilePreview>
onOpenWorkspaceEntry: (
path: string,
type: 'file' | 'directory'
@@ -622,6 +625,10 @@ export function RightAssistantSidebar({
}
>()
const workspacePreviewRequest = useRef(0)
const [workspacePreviewLoadingMore, setWorkspacePreviewLoadingMore] =
useState(false)
const [workspacePreviewLoadMoreError, setWorkspacePreviewLoadMoreError] =
useState('')
const [workspaceRefreshVersion, setWorkspaceRefreshVersion] = useState(0)
const [taskFilter, setTaskFilter] = useState<
'attention' | 'active' | 'paused' | 'finished'
@@ -1037,8 +1044,10 @@ export function RightAssistantSidebar({
workspacePreviewRequest.current = requestId
const projectId = workspaceProjectId
setWorkspacePreview({ projectId, path, state: 'loading' })
setWorkspacePreviewLoadingMore(false)
setWorkspacePreviewLoadMoreError('')
setActionError('')
void onLoadWorkspaceFile(path)
void onLoadWorkspaceFile(path, 0)
.then((file) => {
if (workspacePreviewRequest.current === requestId) {
setWorkspacePreview({
@@ -1065,6 +1074,59 @@ export function RightAssistantSidebar({
})
}
const loadMoreWorkspaceFile = (): void => {
const preview = currentWorkspacePreview
if (
preview?.state !== 'ready' ||
!preview.file.truncated ||
workspacePreviewLoadingMore
) {
return
}
const requestId = workspacePreviewRequest.current
const expectedOffset = preview.file.nextOffsetBytes
setWorkspacePreviewLoadingMore(true)
setWorkspacePreviewLoadMoreError('')
void onLoadWorkspaceFile(preview.path, expectedOffset)
.then((nextPage) => {
if (workspacePreviewRequest.current !== requestId) {
return
}
if (
nextPage.path !== preview.file.path ||
nextPage.offsetBytes !== expectedOffset ||
nextPage.size !== preview.file.size ||
nextPage.mimeType !== preview.file.mimeType
) {
throw new Error(t('sidebar.errors.workspacePreview'))
}
setWorkspacePreview({
projectId: preview.projectId,
path: preview.path,
state: 'ready',
file: {
...nextPage,
content: `${preview.file.content}${nextPage.content}`,
offsetBytes: preview.file.offsetBytes
}
})
})
.catch((reason: unknown) => {
if (workspacePreviewRequest.current === requestId) {
setWorkspacePreviewLoadMoreError(
reason instanceof Error
? reason.message
: t('sidebar.errors.workspacePreview')
)
}
})
.finally(() => {
if (workspacePreviewRequest.current === requestId) {
setWorkspacePreviewLoadingMore(false)
}
})
}
const runAction = async (
action: () => Promise<void>,
fallback: string,
@@ -1426,6 +1488,8 @@ export function RightAssistantSidebar({
onClick={() => {
workspacePreviewRequest.current += 1
setWorkspacePreview(undefined)
setWorkspacePreviewLoadingMore(false)
setWorkspacePreviewLoadMoreError('')
setActionError('')
}}
type="button"
@@ -1471,16 +1535,50 @@ export function RightAssistantSidebar({
</button>
</div>
) : (
<div className="markdown-body markdown-content">
{currentWorkspacePreview.file.mimeType ===
'text/markdown' ? (
<MarkdownRenderer>
{currentWorkspacePreview.file.content}
</MarkdownRenderer>
) : (
<pre>{currentWorkspacePreview.file.content}</pre>
<>
<div className="markdown-body markdown-content">
{currentWorkspacePreview.file.mimeType ===
'text/markdown' ? (
<MarkdownRenderer>
{currentWorkspacePreview.file.content}
</MarkdownRenderer>
) : (
<pre>{currentWorkspacePreview.file.content}</pre>
)}
</div>
{currentWorkspacePreview.file.truncated && (
<div className="assistant-sidebar__preview-more">
<p>
{t('sidebar.workspace.partialPreview', {
loaded: currentWorkspacePreview.file
.nextOffsetBytes.toLocaleString(locale),
total:
currentWorkspacePreview.file.size.toLocaleString(
locale
)
})}
</p>
<button
className="secondary-button"
disabled={workspacePreviewLoadingMore}
onClick={loadMoreWorkspaceFile}
type="button"
>
{workspacePreviewLoadingMore
? t('sidebar.workspace.loadingMore')
: t('sidebar.workspace.loadMore')}
</button>
</div>
)}
</div>
{workspacePreviewLoadMoreError && (
<p
className="assistant-sidebar__preview-more-error"
role="alert"
>
{workspacePreviewLoadMoreError}
</p>
)}
</>
)}
</section>
) : (
+61 -45
View File
@@ -1,11 +1,10 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
ACTIVITY_STORAGE_KEY,
MAX_ACTIVITY_DETAIL_LENGTH,
MAX_ACTIVITY_RECORDS,
loadActivityRecords,
clearLegacyActivityHistory,
loadLegacyActivityHistory,
mergeActivityRecords,
reconcileActivityRecords,
saveActivityRecords,
upsertActivityRecord,
type ActivityRecord
} from './activity-store'
@@ -32,14 +31,14 @@ describe('activity-store', () => {
it('returns an empty history for inaccessible or corrupt storage', () => {
localStorage.setItem(ACTIVITY_STORAGE_KEY, '{invalid')
expect(loadActivityRecords()).toEqual([])
expect(loadLegacyActivityHistory().records).toEqual([])
const inaccessibleStorage = {
getItem: () => {
throw new Error('blocked')
}
} as unknown as Storage
expect(loadActivityRecords(inaccessibleStorage)).toEqual([])
expect(loadLegacyActivityHistory(inaccessibleStorage).records).toEqual([])
})
it('keeps only schema-valid records from untrusted storage', () => {
@@ -49,12 +48,11 @@ describe('activity-store', () => {
JSON.stringify([
validRecord,
{ ...validRecord, status: 'unknown' },
{ ...validRecord, detail: 'x'.repeat(MAX_ACTIVITY_DETAIL_LENGTH + 1) },
null
])
)
expect(loadActivityRecords()).toEqual([validRecord])
expect(loadLegacyActivityHistory().records).toEqual([validRecord])
})
it('loads legacy records with an explicit unavailable scope', () => {
@@ -65,7 +63,7 @@ describe('activity-store', () => {
JSON.stringify([legacyRecord])
)
expect(loadActivityRecords()).toEqual([
expect(loadLegacyActivityHistory().records).toEqual([
expect.objectContaining({
id: legacyRecord.id,
scope: { kind: 'unavailable' }
@@ -89,50 +87,51 @@ describe('activity-store', () => {
])
)
expect(loadActivityRecords()).toEqual([])
expect(loadLegacyActivityHistory().records).toEqual([])
})
it('persists no more than the record limit', () => {
it('loads more than the former record limit without shortening details', () => {
const records = Array.from(
{ length: MAX_ACTIVITY_RECORDS + 1 },
(_, index) => makeRecord(index)
)
expect(saveActivityRecords(records)).toBe(true)
expect(loadActivityRecords()).toHaveLength(MAX_ACTIVITY_RECORDS)
expect(loadActivityRecords().at(-1)?.id).toBe(
`activity-${MAX_ACTIVITY_RECORDS - 1}`
)
})
it('drops the oldest records until the stored JSON fits the load bound', () => {
const records = Array.from(
{ length: MAX_ACTIVITY_RECORDS },
{ length: 501 },
(_, index) => ({
...makeRecord(index),
detail: 'x'.repeat(4_000)
detail: index === 500 ? 'x'.repeat(4_001) : '读取文件'
})
)
localStorage.setItem(ACTIVITY_STORAGE_KEY, JSON.stringify(records))
expect(saveActivityRecords(records)).toBe(true)
const serialized = localStorage.getItem(ACTIVITY_STORAGE_KEY)
expect(serialized?.length).toBeLessThanOrEqual(2_000_000)
const restored = loadActivityRecords()
expect(restored.length).toBeGreaterThan(0)
expect(restored.length).toBeLessThan(MAX_ACTIVITY_RECORDS)
expect(restored[0]?.id).toBe('activity-0')
const legacy = loadLegacyActivityHistory()
expect(legacy.records).toHaveLength(501)
expect(legacy.records.at(-1)?.detail).toHaveLength(4_001)
expect(legacy.historyMayBeIncomplete).toBe(true)
})
it('reports rejected writes without throwing', () => {
const rejectingStorage = {
setItem: () => {
throw new Error('quota exceeded')
}
} as unknown as Storage
expect(saveActivityRecords([makeRecord(1)], rejectingStorage)).toBe(
false
it('marks a legacy maximum-length detail as possibly incomplete', () => {
localStorage.setItem(
ACTIVITY_STORAGE_KEY,
JSON.stringify([{ ...makeRecord(1), detail: 'x'.repeat(4_000) }])
)
expect(loadLegacyActivityHistory()).toMatchObject({
historyMayBeIncomplete: true
})
})
it('clears the legacy migration source without throwing', () => {
localStorage.setItem(
ACTIVITY_STORAGE_KEY,
JSON.stringify([makeRecord(1)])
)
clearLegacyActivityHistory()
expect(localStorage.getItem(ACTIVITY_STORAGE_KEY)).toBeNull()
expect(() =>
clearLegacyActivityHistory({
removeItem: () => {
throw new Error('blocked')
}
} as unknown as Storage)
).not.toThrow()
})
it('upserts transitions for one call while preserving distinct calls', () => {
@@ -183,7 +182,7 @@ describe('activity-store', () => {
).toEqual(first.scope)
})
it('persists and upserts Subagent state transitions', () => {
it('upserts Subagent state transitions', () => {
const queued: ActivityRecord = {
...makeRecord(1),
kind: 'subagent',
@@ -206,8 +205,25 @@ describe('activity-store', () => {
status: 'completed'
})
])
expect(saveActivityRecords(completed)).toBe(true)
expect(loadActivityRecords()).toEqual(completed)
})
it('keeps more than 500 distinct activity records', () => {
const records = Array.from({ length: 501 }, (_, index) =>
makeRecord(index)
)
expect(
upsertActivityRecord(records, makeRecord(501))
).toHaveLength(502)
})
it('merges migrated and persisted records without duplicating IDs', () => {
expect(
mergeActivityRecords(
[makeRecord(2), makeRecord(1)],
[makeRecord(1), makeRecord(0)]
).map((record) => record.id)
).toEqual(['activity-2', 'activity-1', 'activity-0'])
})
it('reconciles stale active records with durable task outcomes', () => {
+62 -180
View File
@@ -1,47 +1,24 @@
import i18n from './i18n'
import type { AssistantTask } from '../../shared/assistant-contracts'
import {
activityRecordSchema,
type ActivityRecord as SharedActivityRecord,
type AssistantTask
} from '../../shared/assistant-contracts'
export const ACTIVITY_STORAGE_KEY = 'goodbuddy.activity-records.v1'
export const MAX_ACTIVITY_RECORDS = 500
export const MAX_ACTIVITY_DETAIL_LENGTH = 4_000
const MAX_STORED_JSON_LENGTH = 2_000_000
const MAX_ID_LENGTH = 256
const MAX_TITLE_LENGTH = 240
const MAX_PROJECT_NAME_LENGTH = 120
const LEGACY_MAX_ACTIVITY_RECORDS = 500
const LEGACY_MAX_ACTIVITY_DETAIL_LENGTH = 4_000
const LEGACY_MAX_STORED_JSON_LENGTH = 2_000_000
const LEGACY_NEAR_STORAGE_LIMIT_LENGTH =
LEGACY_MAX_STORED_JSON_LENGTH - 10_000
const activityKinds = [
'request',
'tool',
'approval',
'subagent',
'result'
] as const
const activityStatuses = [
'pending',
'running',
'completed',
'failed',
'denied',
'cancelled',
'interrupted'
] as const
export type ActivityRecord = SharedActivityRecord
export type ActivityRecord = {
id: string
conversationId: string
requestId: string
callId?: string
scope:
| { kind: 'global' }
| { kind: 'project'; projectId: string; projectName: string }
| { kind: 'unavailable' }
kind: (typeof activityKinds)[number]
title: string
detail: string
status: (typeof activityStatuses)[number]
createdAt: number
export type LegacyActivityHistory = {
records: ActivityRecord[]
historyMayBeIncomplete: boolean
}
function getLocalStorage(): Storage | undefined {
@@ -52,94 +29,16 @@ function getLocalStorage(): Storage | undefined {
}
}
function isBoundedString(
value: unknown,
maximumLength: number,
allowEmpty = false
): value is string {
return (
typeof value === 'string' &&
value.length <= maximumLength &&
(allowEmpty || value.length > 0)
)
}
function parseActivityScope(
value: unknown
): ActivityRecord['scope'] | undefined {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return undefined
}
const candidate = value as Record<string, unknown>
if (candidate.kind === 'global') {
return { kind: 'global' }
}
if (candidate.kind === 'unavailable') {
return { kind: 'unavailable' }
}
if (
candidate.kind === 'project' &&
isBoundedString(candidate.projectId, MAX_ID_LENGTH) &&
isBoundedString(candidate.projectName, MAX_PROJECT_NAME_LENGTH)
) {
return {
kind: 'project',
projectId: candidate.projectId,
projectName: candidate.projectName
}
}
return undefined
}
function parseActivityRecord(value: unknown): ActivityRecord | undefined {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return undefined
}
const candidate = value as Record<string, unknown>
if (
!isBoundedString(candidate.id, MAX_ID_LENGTH) ||
!isBoundedString(candidate.conversationId, MAX_ID_LENGTH) ||
!isBoundedString(candidate.requestId, MAX_ID_LENGTH) ||
(candidate.callId !== undefined &&
!isBoundedString(candidate.callId, MAX_ID_LENGTH)) ||
!activityKinds.some((kind) => kind === candidate.kind) ||
!isBoundedString(candidate.title, MAX_TITLE_LENGTH) ||
!isBoundedString(
candidate.detail,
MAX_ACTIVITY_DETAIL_LENGTH,
true
) ||
!activityStatuses.some((status) => status === candidate.status) ||
typeof candidate.createdAt !== 'number' ||
!Number.isFinite(candidate.createdAt) ||
candidate.createdAt < 0
) {
return undefined
}
const scope =
candidate.scope === undefined
? { kind: 'unavailable' as const }
: parseActivityScope(candidate.scope)
if (!scope) {
return undefined
}
return {
id: candidate.id,
conversationId: candidate.conversationId,
requestId: candidate.requestId,
...(candidate.callId === undefined
? {}
: { callId: candidate.callId }),
scope,
kind: candidate.kind as ActivityRecord['kind'],
title: candidate.title,
detail: candidate.detail,
status: candidate.status as ActivityRecord['status'],
createdAt: candidate.createdAt
}
const parsed = activityRecordSchema.safeParse({
...candidate,
scope: candidate.scope ?? { kind: 'unavailable' }
})
return parsed.success ? parsed.data : undefined
}
export function upsertActivityRecord(
@@ -150,7 +49,7 @@ export function upsertActivityRecord(
(incoming.kind !== 'tool' && incoming.kind !== 'subagent') ||
!incoming.callId
) {
return [incoming, ...records].slice(0, MAX_ACTIVITY_RECORDS)
return [incoming, ...records]
}
const existingIndex = records.findIndex(
@@ -160,7 +59,7 @@ export function upsertActivityRecord(
record.callId === incoming.callId
)
if (existingIndex < 0) {
return [incoming, ...records].slice(0, MAX_ACTIVITY_RECORDS)
return [incoming, ...records]
}
const existing = records[existingIndex]!
@@ -172,7 +71,21 @@ export function upsertActivityRecord(
scope: existing.scope
},
...records.filter((_, index) => index !== existingIndex)
].slice(0, MAX_ACTIVITY_RECORDS)
]
}
export function mergeActivityRecords(
primary: readonly ActivityRecord[],
secondary: readonly ActivityRecord[]
): ActivityRecord[] {
const seen = new Set<string>()
return [...primary, ...secondary].filter((record) => {
if (seen.has(record.id)) {
return false
}
seen.add(record.id)
return true
})
}
function taskTerminalStatus(
@@ -227,38 +140,33 @@ export function reconcileActivityRecords(
? `${record.detail}\n${i18n.t(
'records.interruptedOnRestart',
{ ns: 'activity' }
)}`.slice(
0,
MAX_ACTIVITY_DETAIL_LENGTH
)
)}`
: record.detail
}
})
}
/**
* Loads only records matching the persisted activity schema. Corrupt storage,
* inaccessible storage and oversized payloads are treated as an empty history.
* Reads the previous Renderer-owned history once for migration to SQLite.
* The legacy writer silently stopped at fixed record and payload limits, so
* reaching one of those boundaries is retained as durable uncertainty.
*/
export function loadActivityRecords(
export function loadLegacyActivityHistory(
storage: Storage | undefined = getLocalStorage()
): ActivityRecord[] {
): LegacyActivityHistory {
if (!storage) {
return []
return { records: [], historyMayBeIncomplete: false }
}
try {
const serialized = storage.getItem(ACTIVITY_STORAGE_KEY)
if (
serialized === null ||
serialized.length > MAX_STORED_JSON_LENGTH
) {
return []
if (serialized === null) {
return { records: [], historyMayBeIncomplete: false }
}
const parsed: unknown = JSON.parse(serialized)
if (!Array.isArray(parsed)) {
return []
return { records: [], historyMayBeIncomplete: false }
}
const records: ActivityRecord[] = []
@@ -267,57 +175,31 @@ export function loadActivityRecords(
if (record) {
records.push(record)
}
if (records.length === MAX_ACTIVITY_RECORDS) {
break
}
}
return records
return {
records,
historyMayBeIncomplete:
records.length >= LEGACY_MAX_ACTIVITY_RECORDS ||
serialized.length >= LEGACY_NEAR_STORAGE_LIMIT_LENGTH ||
records.some(
(record) =>
record.detail.length === LEGACY_MAX_ACTIVITY_DETAIL_LENGTH
)
}
} catch {
return []
return { records: [], historyMayBeIncomplete: false }
}
}
/**
* Persists as many of the newest schema-valid records as fit within the
* bounded storage payload. Returns false if storage is unavailable or rejects
* the write.
*/
export function saveActivityRecords(
records: readonly ActivityRecord[],
export function clearLegacyActivityHistory(
storage: Storage | undefined = getLocalStorage()
): boolean {
): void {
if (!storage) {
return false
return
}
const serializedRecords: string[] = []
let serializedLength = 2
for (const record of records) {
const safeRecord = parseActivityRecord(record)
if (safeRecord) {
const serializedRecord = JSON.stringify(safeRecord)
const nextLength =
serializedLength +
serializedRecord.length +
(serializedRecords.length > 0 ? 1 : 0)
if (nextLength > MAX_STORED_JSON_LENGTH) {
break
}
serializedRecords.push(serializedRecord)
serializedLength = nextLength
}
if (serializedRecords.length === MAX_ACTIVITY_RECORDS) {
break
}
}
try {
storage.setItem(
ACTIVITY_STORAGE_KEY,
`[${serializedRecords.join(',')}]`
)
return true
storage.removeItem(ACTIVITY_STORAGE_KEY)
} catch {
return false
// A failed cleanup leaves the migration source intact for the next start.
}
}
@@ -115,12 +115,15 @@ export const activity = {
unknownTime: 'Unknown time',
interruptedOnRestart:
'This activity had not finished when the app restarted.',
legacyHistoryMayBeIncomplete:
'Older versions limited local run history and detail length, so earlier records or parts of their details may already be missing. New records are saved in full.',
unavailableScope:
'The scope could not be determined when this activity was recorded.',
conversation: 'Conversation: {{title}}',
activityCount: 'Activity items: {{formattedCount}}',
projectSummary:
'{{conversationCount}} tasks or conversations · {{activityCount}} activity items',
loadMore: 'Show more ({{remaining}} remaining)',
openConversation: 'Open conversation'
}
} satisfies TranslationShape<typeof chineseActivity>
@@ -643,6 +643,9 @@ export const app = {
'Failed to resume this conversation’s send queue',
conversationPersistenceFailed:
'Failed to save conversations. Check local storage.',
activityHistoryPersistenceFailed:
'Failed to save run history. Check local storage.',
activityHistoryReadFailed: 'Failed to read run history',
remoteConversationRefreshFailed:
'Failed to refresh remote channel conversations',
projectReadFailed: 'Failed to read projects',
@@ -327,6 +327,9 @@ export const workspace = {
fileSize: '{{formattedSize}} bytes',
fileFallback: 'Project workspace file',
reading: 'Reading file…',
partialPreview: 'Loaded {{loaded}} / {{total}} bytes',
loadMore: 'Load more',
loadingMore: 'Loading…',
refreshAriaLabel: 'Refresh workspace files',
refresh: 'Refresh',
gitUnavailable: 'Git status unavailable: {{error}}',
@@ -111,12 +111,15 @@ export const activity = {
records: {
unknownTime: '时间未知',
interruptedOnRestart: '应用重启时此活动尚未结束。',
legacyHistoryMayBeIncomplete:
'旧版本曾限制本机运行记录的数量和详情长度,因此更早的记录或部分详情可能已经丢失。新记录会完整保存。',
unavailableScope:
'创建此活动记录时未能确定其归属范围。',
conversation: '对话:{{title}}',
activityCount: '{{formattedCount}} 条活动',
projectSummary:
'{{conversationCount}} 个任务或会话 · {{activityCount}} 条活动',
loadMore: '继续显示(剩余 {{remaining}} 条)',
openConversation: '打开所属对话'
}
} as const
@@ -595,6 +595,8 @@ export const app = {
conversationQueueResumeFailed:
'无法继续执行当前对话的待发送队列',
conversationPersistenceFailed: '会话持久化失败,请检查本地存储',
activityHistoryPersistenceFailed: '运行记录保存失败,请检查本地存储',
activityHistoryReadFailed: '运行记录读取失败',
remoteConversationRefreshFailed: '远程通道会话刷新失败',
projectReadFailed: '项目读取失败',
expertsReadFailed: '专家角色读取失败',
@@ -318,6 +318,9 @@ export const workspace = {
fileSize: '{{formattedSize}} 字节',
fileFallback: '项目工作区文件',
reading: '正在读取文件…',
partialPreview: '已加载 {{loaded}} / {{total}} 字节',
loadMore: '继续加载',
loadingMore: '正在加载…',
refreshAriaLabel: '刷新工作区文件',
refresh: '刷新',
gitUnavailable: 'Git 状态不可用:{{error}}',
+33
View File
@@ -4959,6 +4959,25 @@ button > svg {
font-size: 9px;
}
.assistant-sidebar__preview-more {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) 0;
gap: var(--space-3);
}
.assistant-sidebar__preview-more p,
.assistant-sidebar__preview-more-error {
margin: 0;
color: var(--text-muted);
font-size: 10px;
}
.assistant-sidebar__preview-more-error {
color: var(--danger);
}
.assistant-sidebar__import {
display: flex;
align-items: center;
@@ -13729,6 +13748,20 @@ details.settings-section > :not(summary) + :not(summary) {
padding: var(--space-4) 0;
}
.activity-panel__legacy-warning {
margin: var(--space-3) 0 0;
padding: var(--space-3);
border: 1px solid color-mix(in srgb, var(--warning) 45%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--warning) 10%, transparent);
color: var(--text-secondary);
}
.activity-panel__load-more {
align-self: center;
margin-top: var(--space-3);
}
.activity-panel__tab-panel {
min-width: 0;
padding-bottom: var(--space-6);
+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',