Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions .playwright/scripts/__tests__/sessionReferences.e2e.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { Buffer } from 'node:buffer'
import { once } from 'node:events'
import fs from 'node:fs/promises'
import { createServer } from 'node:http'
import path from 'node:path'
import { expect, test } from '../fixtures/electron.mjs'

test('referenced task resources are discovered, read through the runtime and opened beside the current draft', async ({ buddy }, testInfo) => {
const title = '创建一个 HTML,内容是 SVG 绘制一只鹅骑自行车的二维动画,保留完整标题以验证单行省略'
const html = `<!doctype html><title>Goose animation</title>${'<circle cx="20" cy="20" r="10"/>'.repeat(600)}END_OF_ANIMATION`
const requests = []
const readParts = []
let step = 0
let source
const server = createServer(async (request, response) => {
if (request.url !== '/v1/chat/completions') {
response.writeHead(404).end()
return
}
const chunks = []
for await (const chunk of request) chunks.push(chunk)
const body = JSON.parse(Buffer.concat(chunks).toString())
requests.push(body)
const common = { id: `materials-${requests.length}`, model: body.model, object: 'chat.completion.chunk', created: 1 }
function send(delta, reason = 'stop') {
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.write(`data: ${JSON.stringify({ ...common, choices: [{ index: 0, delta: { role: 'assistant', ...delta }, finish_reason: null }] })}\n\n`)
response.write(`data: ${JSON.stringify({ ...common, choices: [{ index: 0, delta: {}, finish_reason: reason }], usage: { prompt_tokens: 20, completion_tokens: 10, total_tokens: 30 } })}\n\n`)
response.end('data: [DONE]\n\n')
}
function call(name, args) {
send({ tool_calls: [{ index: 0, id: `call-${requests.length}`, type: 'function', function: { name, arguments: JSON.stringify(args) } }] }, 'tool_calls')
}
if (!body.tools?.length) {
send({ content: '继续制作动画' })
return
}
if (step++ === 0) {
call('lexora_tool_search', { toolNames: ['lexora_session_search', 'lexora_session_read'] })
return
}
if (step === 2) {
call('lexora_session_search', { sessionId: 'source', query: 'goose', kind: 'resources' })
return
}
try {
const last = JSON.parse(body.messages.findLast(message => message.role === 'tool').content)
if (step === 3) {
source = last.resources[0].source
call('lexora_session_read', { sessionId: 'source', source, limit: 8000 })
return
}
readParts.push(last.text)
if (last.nextOffset !== null) {
call('lexora_session_read', { sessionId: 'source', source, offset: last.nextOffset, limit: 8000 })
return
}
send({ content: '已完整读取动画源码,可以继续修改夜景。' })
}
catch {
send({ content: 'Fixture could not read the task resource.' })
}
})
server.listen(0, '127.0.0.1')
await once(server, 'listening')
const instance = await buddy.createInstance('session-materials')
try {
const { app, page, diagnostics } = await instance.launch()
const resourcePath = path.join(instance.home, 'goose-animation.html')
await fs.writeFile(resourcePath, html)
await app.evaluate(({ app, safeStorage }, fixture) => {
if (app.getName() !== 'Lexora Buddy Test')
throw new Error('Synthetic credentials require an isolated test instance')
Object.defineProperties(safeStorage, {
isEncryptionAvailable: { configurable: true, value: () => true },
getSelectedStorageBackend: { configurable: true, value: () => 'offline-fixture' },
encryptString: { configurable: true, value: value => Buffer.from(`offline-fixture:${value}`) },
decryptString: { configurable: true, value: value => value.toString('utf8').slice('offline-fixture:'.length) },
})
const { DatabaseSync } = process.getBuiltinModule('node:sqlite')
const database = new DatabaseSync(fixture.databasePath)
const now = new Date().toISOString()
try {
database.prepare('INSERT INTO conversations (id, title, active_branch_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)').run('source', fixture.title, 'source-branch', now, now)
database.prepare('INSERT INTO conversation_branches (id, conversation_id, created_at) VALUES (?, ?, ?)').run('source-branch', 'source', now)
const content = {
userContent: { version: 1, body: [{ type: 'paragraph', content: [{ type: 'text', text: '继续完善这个文件。' }] }], panelResourceIds: ['animation'] },
resourceSnapshots: [{ resourceId: 'animation', localReference: { path: fixture.resourcePath, name: 'goose-animation.html', kind: 'file', mimeType: 'text/html', sizeBytes: fixture.sizeBytes } }],
}
database.prepare('INSERT INTO messages (id, conversation_id, branch_id, role, content_json, created_at) VALUES (?, ?, ?, ?, ?, ?)').run('source-message', 'source', 'source-branch', 'user', JSON.stringify(content), now)
}
finally { database.close() }
}, { databasePath: path.join(instance.home, 'buddy/buddy.sqlite3'), resourcePath, sizeBytes: Buffer.byteLength(html), title })
await page.evaluate(() => window.lexoraDesktop.localChat.runtime.restart())
await expect.poll(async () => (await page.evaluate(() => window.lexoraDesktop.localChat.runtime.getStatus())).status).toBe('ready')
await page.evaluate(async (baseUrl) => {
const providers = window.lexoraDesktop.localChat.providers
await providers.upsertCustom({ id: 'materials-fixture', displayName: 'Materials fixture', api: 'openai-completions', baseUrl, enabled: true, models: [{ id: 'materials', name: 'Materials', input: ['text'], reasoning: false, contextWindow: 128000, maxTokens: 1024 }] })
const stop = providers.onAuthChallenge((challenge) => {
if (challenge.providerId === 'materials-fixture' && challenge.type === 'secret')
void providers.respondToAuth(challenge.challengeId, 'offline-fixture-key')
})
try {
await providers.login('materials-fixture', 'api_key')
}
finally { stop() }
await providers.setDefaultModel({ providerId: 'materials-fixture', modelId: 'materials', reasoning: null })
}, `http://127.0.0.1:${server.address().port}/v1`)
await page.reload()
const editor = page.locator('.desktop-chat-composer__prosemirror:visible')
await expect(editor).toBeVisible()
await editor.fill('@')
await page.getByRole('option', { name: '独立任务', exact: true }).click()
await expect(page.locator('.chat-composer-source-picker__shortcuts')).toContainText('Alt')
await page.getByRole('option', { name: title, exact: true }).click()
await expect(page.locator('.chat-session-reference')).toHaveCount(1)
const mention = editor.locator('[data-type="chat-session-reference"]')
await expect(mention).toBeVisible()
expect(await mention.evaluate(element => getComputedStyle(element).whiteSpace)).toBe('nowrap')
await editor.press('End')
await page.keyboard.insertText(' 把之前生成的动画改成夜景。')
await page.getByRole('button', { name: '发送消息', exact: true }).click()
await expect(page.getByText('已完整读取动画源码,可以继续修改夜景。', { exact: true })).toBeVisible({ timeout: 60000 })
expect(readParts.join('')).toBe(html)
expect(readParts.length).toBeGreaterThan(1)
expect(requests.filter(body => body.tools?.length).some(body => body.tools.some(tool => tool.function.name === 'lexora_session_read'))).toBe(true)
await page.getByRole('button', { name: /读取 \d+ 次 · 搜索 \d+ 次/ }).click()
await expect(page.getByRole('button', { name: /搜索任务资料/ })).toBeVisible()
await page.getByRole('button', { name: /读取任务资料/ }).first().click()
await expect(page.getByRole('button', { name: `打开来源任务 · ${title}`, exact: true })).toBeVisible()
await editor.fill('保留在当前任务的后续修改要求')
await page.getByRole('button', { name: `打开来源任务 · ${title}`, exact: true }).click()
await expect(page.locator('.desktop-chat-composer__prosemirror:visible')).toHaveCount(2)
await expect(page.locator('.desktop-chat-composer__prosemirror:visible').filter({ hasText: '保留在当前任务的后续修改要求' })).toHaveCount(1)
await expect(page.locator('.workbench-pane-title:visible').filter({ hasText: title })).toHaveCount(1)
await page.screenshot({ path: testInfo.outputPath('session-materials-source-open.png'), animations: 'disabled' })
expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([])
expect(await fs.readFile(resourcePath, 'utf8')).toBe(html)
}
finally {
server.closeAllConnections()
await new Promise(resolve => server.close(resolve))
}
})
3 changes: 1 addition & 2 deletions apps/buddy/electron/main/local-chat/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { conversationRequestSchemas, conversationsRpc } from '../../../shared/co
import { conversationTreeRpc } from '../../../shared/conversation/conversationTree'
import { LOCAL_WORKSPACE_STATE_KEY, workspaceRequestSchemas, workspaceStateRpc } from '../../../shared/conversation/workspaceApi'
import { runsRequestSchemas } from '../../../shared/runs/runApi'
import { validationRequestSchemas } from '../../../shared/runtime/apiValidation'
import { LOCAL_CHAT_IPC_CHANNELS } from '../../shared/localChatApi'

export function registerConversationIpc(context: LocalChatIpcContext): void {
Expand All @@ -29,7 +28,7 @@ export function registerConversationIpc(context: LocalChatIpcContext): void {

handle(LOCAL_CHAT_IPC_CHANNELS.conversationsGetTree, (_event, input) => request(conversationTreeRpc.get, conversationTreeRpc.get.input.parse(input)))

handle(LOCAL_CHAT_IPC_CHANNELS.conversationsList, (_event, input) => request(conversationsRpc.list, validationRequestSchemas.limit.parse(input)))
handle(LOCAL_CHAT_IPC_CHANNELS.conversationsList, (_event, input) => request(conversationsRpc.list, conversationRequestSchemas.list.parse(input)))

handle(LOCAL_CHAT_IPC_CHANNELS.conversationsGet, (_event, input) => request(conversationsRpc.get, conversationRequestSchemas.conversationId.parse(input)))

Expand Down
2 changes: 1 addition & 1 deletion apps/buddy/electron/preload/local-chat/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function createConversationApi(): Pick<LocalChatApi, 'context' | 'workspa
onTimelineChanged: listener => subscribe(LOCAL_CHAT_IPC_CHANNELS.conversationsTimelineChanged, listener),
getNodeDetail: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.conversationsGetNodeDetail, input),
getTree: conversationId => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.conversationsGetTree, { conversationId }),
list: limit => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.conversationsList, { limit }),
list: (limit, filter) => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.conversationsList, { limit, filter: filter ? { spaceId: filter.spaceId, query: filter.query, excludeConversationId: filter.excludeConversationId } : undefined }),
get: conversationId => ipcRenderer.invoke(
LOCAL_CHAT_IPC_CHANNELS.conversationsGet,
{ conversationId },
Expand Down
4 changes: 2 additions & 2 deletions apps/buddy/electron/shared/localChatApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type {
BuddyComposerSpaceFileSelect,
} from '../../shared/conversation/composerResource'
import type { LocalContextUsageSnapshot, LocalContextUsageSnapshotRequest } from '../../shared/conversation/contextApi'
import type { ConversationTimelineChangeNotice, LocalConversation, LocalConversationBranch, LocalConversationSummary, LocalConversationTimelinePage, LocalMessagePage } from '../../shared/conversation/conversationApi'
import type { ConversationListFilter, ConversationTimelineChangeNotice, LocalConversation, LocalConversationBranch, LocalConversationSummary, LocalConversationTimelinePage, LocalMessagePage } from '../../shared/conversation/conversationApi'
import type { ConversationNodeDetailRequest, LocalConversationTree } from '../../shared/conversation/conversationTree'
import type { LocalTaskMark, LocalTaskMarkState, TaskMarkClearInput, TaskMarkInput, TaskMarkReadInput } from '../../shared/conversation/taskMarkApi'
import type { LocalWorkspaceSetting, LocalWorkspaceStateValue } from '../../shared/conversation/workspaceApi'
Expand Down Expand Up @@ -386,7 +386,7 @@ export interface LocalChatApi {
onTimelineChanged: (listener: (event: ConversationTimelineChangeNotice) => void) => () => void
getNodeDetail: (input: ConversationNodeDetailRequest) => Promise<LocalConversationTimelinePage>
getTree: (conversationId: string) => Promise<LocalConversationTree>
list: (limit?: number) => Promise<ReadonlyArray<LocalConversationSummary>>
list: (limit?: number, filter?: ConversationListFilter) => Promise<ReadonlyArray<LocalConversationSummary>>
get: (conversationId: string) => Promise<LocalConversation>
delete: (conversationId: string) => Promise<boolean>
activateBranch: (input: {
Expand Down
2 changes: 2 additions & 0 deletions apps/buddy/service/src/BuddyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,8 @@ export async function startBuddyService(
imageGenerationGateway,
imageTransformService,
webService,
conversations,
eventLog: options.eventLog,
}, petActions),
}
const sessionBlueprints = new BuddySessionBlueprintService({
Expand Down
2 changes: 1 addition & 1 deletion apps/buddy/service/src/agent/context/projectReadHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export function projectReadResult(
message: ToolResultMessage,
options?: { omitImages?: boolean },
): ToolResultMessage {
if (message.toolName !== 'read' || message.isError)
if (!['read', 'lexora_session_read'].includes(message.toolName) || message.isError)
return message
const hasBinaryText = message.content.some(block => block.type === 'text' && isHistoricalBinaryRead(block.text))
const hasImage = options?.omitImages && message.content.some(block => block.type === 'image')
Expand Down
2 changes: 2 additions & 0 deletions apps/buddy/service/src/agent/events/toolPresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { createOutputPresentRunOutput } from '../../artifacts/artifactToolContra
import { createAutomationToolPresentation } from '../../automations/automationToolContract'
import { createBrowserToolPresentation } from '../../browser/browserToolPresentation'
import { createMcpRunOutput, createMcpToolPresentation } from '../../connectors/mcp/mcpToolContract'
import { createSessionReferenceToolPresentation } from '../../conversations/sessionReferenceToolContract'
import {
argumentNames,
boundedToolPreview,
Expand Down Expand Up @@ -46,6 +47,7 @@ export function createBuddyToolPresentation(
input: CreateBuddyToolPresentationInput,
): BuddyToolPresentation {
return createPiToolPresentation(input)
?? createSessionReferenceToolPresentation(input)
?? createWebToolPresentation(input)
?? createPetToolPresentation(input)
?? createImageGenerationToolPresentation(input)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,13 @@ export function createToolDiscoveryCapability(policies: readonly BuddyToolDisclo
sync(context, 'request')
})
pi.on('turn_end', (_event, context) => sync(context, 'request'))
pi.on('context', (_event, context) => sync(context, 'context'))
pi.on('context_with_system', (event, context) => {
sync(context, 'context')
const active = new Set(pi.getActiveTools())
return { messages: event.messages.map(message => message.role === 'system' && message.toolsAdded
? { ...message, toolsAdded: message.toolsAdded.filter(tool => active.has(tool.name)) }
: message) }
})
pi.on('model_select', (_event, context) => sync(context, 'model'))
pi.on('session_tree', (_event, context) => restore(context, 'tree'))
pi.on('session_shutdown', () => state.dispose())
Expand Down
53 changes: 10 additions & 43 deletions apps/buddy/service/src/agent/extensions/readFileExtension.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import type { ReadToolDetails, ReadToolOptions, ToolDefinition } from '@earendil-works/pi-coding-agent'
import type { BuddyInProcessExtension } from './BuddyInProcessExtension'
import { Buffer } from 'node:buffer'
import { constants } from 'node:fs'
import { open } from 'node:fs/promises'
import { createReadToolDefinition, detectSupportedImageMimeTypeFromFile } from '@earendil-works/pi-coding-agent'
import { createReadToolDefinition } from '@earendil-works/pi-coding-agent'
import { filePaths } from '../../../../platform/filesystem/filePaths'
import { projectReadHistory } from '../context/projectReadHistory'
import { decodeReadText, detectBinaryReadFormat, READ_CONTENT_NOTICE } from '../files/readFileContent'
import { READ_CONTENT_NOTICE } from '../files/readFileContent'
import { readFileSnapshot } from '../files/readFileSnapshot'

export const READ_FILE_EXTENSION = 'lexora-read-file'

Expand All @@ -23,52 +22,20 @@ export function createBuddyReadTool(cwd: string, options?: Pick<ReadToolOptions,
async execute(toolCallId, parameters, signal, onUpdate, context) {
signal?.throwIfAborted()
const path = filePaths.resolveInput(parameters.path, context.cwd || cwd)
const handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK)
let bytes: Buffer
try {
const metadata = await handle.stat()
if (!metadata.isFile())
throw new Error('read requires a regular file. Use a directory listing tool for directories; devices and pipes cannot be read.')
const header = Buffer.alloc(16)
const { bytesRead } = await handle.read(header, 0, header.length, 0)
const format = detectBinaryReadFormat(header.subarray(0, bytesRead))
if (format || metadata.size > 32 * 1024 * 1024) {
const description = format ?? 'File exceeding the 32 MiB read memory limit'
return {
content: [{ type: 'text', text: `${description}, ${metadata.size} bytes. ${READ_CONTENT_NOTICE}` }],
details: { contentOmitted: { format: description, sizeBytes: metadata.size } },
}
}
bytes = Buffer.alloc(metadata.size)
let position = 0
while (position < bytes.length) {
signal?.throwIfAborted()
const read = await handle.read(bytes, position, Math.min(64 * 1024, bytes.length - position), position)
if (!read.bytesRead)
break
position += read.bytesRead
}
bytes = bytes.subarray(0, position)
}
finally {
await handle.close()
}
signal?.throwIfAborted()
const format = detectBinaryReadFormat(bytes)
if (format) {
const snapshot = await readFileSnapshot(path, signal)
if (snapshot.kind === 'omitted') {
const { format, sizeBytes } = snapshot
return {
content: [{ type: 'text', text: `${format}, ${bytes.length} bytes. ${READ_CONTENT_NOTICE}` }],
details: { contentOmitted: { format, sizeBytes: bytes.length } },
content: [{ type: 'text', text: `${format}, ${sizeBytes} bytes. ${READ_CONTENT_NOTICE}` }],
details: { contentOmitted: { format, sizeBytes } },
}
}
const mimeType = await detectSupportedImageMimeTypeFromFile(path)
const snapshot = mimeType ? bytes : Buffer.from(decodeReadText(bytes))
const reader = createReadToolDefinition(cwd, {
...options,
operations: {
access: async () => {},
readFile: async () => snapshot,
detectImageMimeType: async () => mimeType,
readFile: async () => snapshot.kind === 'image' ? snapshot.bytes : Buffer.from(snapshot.text),
detectImageMimeType: async () => snapshot.kind === 'image' ? snapshot.mimeType : null,
},
})
return reader.execute(toolCallId, parameters, signal, onUpdate, context)
Expand Down
Loading