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:
@@ -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()
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
Reference in New Issue
Block a user