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
105 changes: 105 additions & 0 deletions .playwright/scripts/__tests__/task-space.e2e.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { expect, test } from '../fixtures/electron.mjs'

test('moving a task requires confirmation, preserves its draft and previous files, and survives restart', async ({ buddy }) => {
const instance = await buddy.createInstance('task-space')
const target = path.join(instance.home, 'project')
const previous = path.join(instance.home, 'buddy/conversations/movable/workspace')
await fs.mkdir(target, { recursive: true })
await fs.mkdir(previous, { recursive: true })
await fs.writeFile(path.join(previous, 'unfinished.txt'), 'Original unfinished work')
let { app, page, diagnostics } = await instance.launch()
await app.evaluate(({ dialog }, { target, databasePath }) => {
dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [target] })
const { DatabaseSync } = process.getBuiltinModule('node:sqlite')
const db = new DatabaseSync(databasePath)
try {
const now = new Date().toISOString()
for (const id of ['movable', 'paused']) {
db.prepare('INSERT INTO conversations (id, title, active_branch_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)').run(id, id === 'movable' ? '归入项目的任务' : '保留待发消息的任务', `branch-${id}`, now, now)
db.prepare('INSERT INTO conversation_branches (id, conversation_id, created_at) VALUES (?, ?, ?)').run(`branch-${id}`, id, now)
db.prepare('INSERT INTO messages (id, conversation_id, branch_id, role, content_json, created_at) VALUES (?, ?, ?, ?, ?, ?)').run(`message-${id}`, id, `branch-${id}`, 'user', JSON.stringify({ text: 'Existing task history' }), now)
}
const queued = { conversationId: 'paused', branchId: 'branch-paused', buddyId: 'default', approvalPolicy: 'policy', executionProfile: 'workspace_write', spaceId: null, queueRouting: { mode: 'current_actor' }, runInput: { attachmentIds: [] }, userMessageContent: { userContent: { version: 1, body: [{ type: 'paragraph', content: [{ type: 'text', text: 'Preserved queued input' }] }], panelResourceIds: [] }, resourceSnapshots: [] } }
db.prepare(`INSERT INTO chat_queue (id, conversation_id, branch_id, request_id, request_fingerprint, prepared_json, state, created_at) VALUES ('pending', 'paused', 'branch-paused', 'pending-request', 'pending-fingerprint', ?, 'paused', ?)`).run(JSON.stringify(queued), now)
}
finally { db.close() }
}, { target, databasePath: path.join(instance.home, 'buddy/buddy.sqlite3') })
await page.reload()
const spaces = page.locator('.desktop-task-sidebar__spaces')
await spaces.locator('.desktop-task-sidebar__section-heading').hover()
await spaces.locator('.desktop-task-sidebar__section-add').click()
const spaceDialog = page.locator('.desktop-space-dialog')
await spaceDialog.getByPlaceholder('输入空间名称').fill('目标项目')
await spaceDialog.getByRole('button', { name: '选择目录', exact: true }).click()
await spaceDialog.getByRole('button', { name: '确定', exact: true }).click()
await expect(spaceDialog).toBeHidden()
const spaceId = await page.evaluate(async () => (await window.lexoraDesktop.localChat.spaces.list()).find(space => space.name === '目标项目').id)
let rowSelector = '[data-task-id="movable"]'
const row = () => page.locator(rowSelector)
await row().locator('.desktop-task-sidebar__task').click()
const editor = () => page.locator('.desktop-chat-composer__prosemirror:visible')
await editor().fill('移入后仍需保留的草稿')
const openMoveDialog = async () => {
await row().hover()
await row().getByRole('button', { name: '更多操作', exact: true }).click()
await page.locator('.n-dropdown-menu:visible').getByText('移入空间', { exact: true }).click()
const dialog = page.locator('.desktop-task-move-dialog')
await expect(dialog).toBeVisible()
await dialog.locator('.n-base-selection').click()
await page.locator('.n-base-select-menu:visible').getByText('目标项目', { exact: true }).click()
await expect(dialog).toContainText('文件不搬迁')
return dialog
}
let moveDialog = await openMoveDialog()
await moveDialog.getByRole('button', { name: '取消', exact: true }).click()
expect((await page.evaluate(() => window.lexoraDesktop.localChat.conversations.get('movable'))).spaceId).toBeNull()
await expect(editor()).toContainText('移入后仍需保留的草稿')
moveDialog = await openMoveDialog()
await page.screenshot({ path: path.join(instance.artifactDirectory, '01-move-confirmation.png'), animations: 'disabled' })
await moveDialog.getByRole('button', { name: '移入', exact: true }).click()
await expect(moveDialog).toBeHidden()
await expect.poll(() => page.evaluate(() => window.lexoraDesktop.localChat.conversations.get('movable'))).toMatchObject({ spaceId, activeBranchId: 'branch-movable' })
await expect(row()).toBeVisible()
await expect(editor()).toContainText('移入后仍需保留的草稿')
expect(await fs.readFile(path.join(previous, 'unfinished.txt'), 'utf8')).toBe('Original unfinished work')
expect(await fs.readdir(target)).toEqual([])
const expectMoveHidden = async () => {
await row().hover()
await row().getByRole('button', { name: '更多操作', exact: true }).click()
await expect(page.locator('.n-dropdown-menu:visible')).toBeVisible()
await expect(page.locator('.n-dropdown-menu:visible').getByText('移入空间', { exact: true })).toHaveCount(0)
await editor().click()
await expect(page.locator('.n-dropdown-menu:visible')).toHaveCount(0)
await row().click({ button: 'right' })
await expect(page.locator('.n-dropdown-menu:visible')).toBeVisible()
await expect(page.locator('.n-dropdown-menu:visible').getByText('移入空间', { exact: true })).toHaveCount(0)
await editor().click()
await expect(page.locator('.n-dropdown-menu:visible')).toHaveCount(0)
}
await expectMoveHidden()
const spaceRow = page.locator('.desktop-task-space-row').filter({ hasText: '目标项目' })
await spaceRow.hover()
await spaceRow.getByRole('button', { name: '置顶', exact: true }).click()
rowSelector = '.desktop-task-sidebar__pinned [data-task-id="movable"]'
await expect(row()).toBeVisible()
await expectMoveHidden()
const queueError = await page.evaluate(async (spaceId) => {
try {
await window.lexoraDesktop.localChat.conversations.moveToSpace('paused', spaceId)
return null
}
catch (error) { return error.message }
}, spaceId)
expect(queueError).toContain('CONVERSATION_QUEUE_NOT_EMPTY')
await page.screenshot({ path: path.join(instance.artifactDirectory, '02-task-in-space.png'), animations: 'disabled' })
expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([])
await instance.stop()
;({ app, page, diagnostics } = await instance.launch())
await expect(row()).toBeVisible()
await expect(editor()).toContainText('移入后仍需保留的草稿')
expect((await page.evaluate(() => window.lexoraDesktop.localChat.conversations.get('movable'))).spaceId).toBe(spaceId)
expect(await fs.readFile(path.join(previous, 'unfinished.txt'), 'utf8')).toBe('Original unfinished work')
expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([])
})
2 changes: 2 additions & 0 deletions apps/buddy/electron/main/local-chat/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export function registerConversationIpc(context: LocalChatIpcContext): void {

handle(LOCAL_CHAT_IPC_CHANNELS.conversationsSetBuddy, (_event, input) => request(conversationsRpc.setBuddy, conversationRequestSchemas.conversationBuddy.parse(input)))

handle(LOCAL_CHAT_IPC_CHANNELS.conversationsMoveToSpace, (_event, input) => request(conversationsRpc.moveToSpace, conversationRequestSchemas.conversationMoveToSpace.parse(input)))

handle(LOCAL_CHAT_IPC_CHANNELS.conversationsSetModelSelection, (_event, input) => request(conversationsRpc.setModelSelection, conversationRequestSchemas.conversationModelSelection.parse(input)))

handle(LOCAL_CHAT_IPC_CHANNELS.conversationsListTimeline, (_event, input) => request(conversationsRpc.listTimeline, conversationRequestSchemas.conversationTimeline.parse(input)))
Expand Down
1 change: 1 addition & 0 deletions apps/buddy/electron/preload/local-chat/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export function createConversationApi(): Pick<LocalChatApi, 'context' | 'workspa
addMember: (conversationId, buddyId) => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.conversationsAddMember, { conversationId, buddyId }),
removeMember: (conversationId, buddyId) => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.conversationsRemoveMember, { conversationId, buddyId }),
setBuddy: (conversationId, buddyId) => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.conversationsSetBuddy, { conversationId, buddyId }),
moveToSpace: (conversationId, spaceId) => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.conversationsMoveToSpace, { conversationId, spaceId }),
setModelSelection: (conversationId, modelSelection, target) => ipcRenderer.invoke(
LOCAL_CHAT_IPC_CHANNELS.conversationsSetModelSelection,
{ conversationId, modelSelection, ...(target?.actorId ? { actorId: target.actorId } : {}), ...(target?.runId ? { targetRunId: target.runId } : {}) },
Expand Down
2 changes: 2 additions & 0 deletions apps/buddy/electron/shared/localChatApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export const LOCAL_CHAT_IPC_CHANNELS = {
conversationsAddMember: 'lexora:buddy:conversations:add-member',
conversationsRemoveMember: 'lexora:buddy:conversations:remove-member',
conversationsSetBuddy: 'lexora:local-chat:conversations:set-buddy',
conversationsMoveToSpace: 'lexora:buddy:conversations:move-to-space',
conversationsSetModelSelection: 'lexora:buddy:conversations:set-model-selection',
conversationsListTimeline: 'lexora:buddy:conversations:list-timeline',
notificationsChanged: 'lexora:buddy:notifications:changed',
Expand Down Expand Up @@ -483,6 +484,7 @@ export interface LocalChatApi {
addMember: (conversationId: string, buddyId: string) => Promise<readonly LocalConversationMember[]>
removeMember: (conversationId: string, buddyId: string) => Promise<readonly LocalConversationMember[]>
setBuddy: (conversationId: string, buddyId: string) => Promise<LocalConversation>
moveToSpace: (conversationId: string, spaceId: string) => Promise<LocalConversation>
setModelSelection: (
conversationId: string,
modelSelection: LocalConversation['modelSelection'],
Expand Down
44 changes: 40 additions & 4 deletions apps/buddy/service/src/BuddyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { BuddyRuntime } from './BuddyRuntime'
import type { RunEventLogPort } from './events/RunEventPorts'
import type { BuddyServiceRpcServer } from './rpc/BuddyServiceRpcServer'
import type { BuddyServiceErrorCode } from './rpc/runtimeRequest'
import type { ConversationRecord } from './storage/conversationRecord'
import { randomUUID } from 'node:crypto'
import { mkdir } from 'node:fs/promises'
import { join } from 'node:path'
Expand Down Expand Up @@ -114,6 +115,7 @@ import { ContextUsageSnapshotService } from './context/ContextUsageSnapshotServi
import { registerContextRpc } from './context/registerContextRpc'
import { ConversationLifecycleService } from './conversations/ConversationLifecycleService'
import { ConversationMetadataService } from './conversations/ConversationMetadataService'
import { ConversationMutationGuard } from './conversations/ConversationMutationGuard'
import { createExtensionTaskCapabilities } from './conversations/extensionTaskCapabilities'
import { ExtensionTaskContext } from './conversations/ExtensionTaskContext'
import { registerConversationRpc } from './conversations/registerConversationRpc'
Expand Down Expand Up @@ -256,6 +258,20 @@ export async function startBuddyService(
const conversations = createConversationRepository(options.database)
const conversationHistory = createConversationHistoryStore(options.database)
const runs = createRunRepository(options.database)
const conversationMutations = new ConversationMutationGuard()
const conversationAvailability = {
assertConversationWritable: (id: string) => {
conversationMutations.assertWritable(id)
options.eventLog.assertConversationWritable(id)
},
assertRunExecutable: (id: string) => {
const run = runs.findById(id)
if (run)
conversationMutations.assertWritable(run.conversationId)
options.eventLog.assertRunExecutable(id)
},
}
const chatQueue = createChatQueueRepository(options.database)
const executionRecords = createExecutionRepository(options.database)
const threads = createThreadBindingRepository(options.database)
const treeRepository = createConversationTreeRepository(options.database)
Expand Down Expand Up @@ -301,7 +317,7 @@ export async function startBuddyService(
const usageRepository = createUsageRepository(options.database)
const workspace = createWorkspaceRepository(options.database)
const turnRequests = await host.start('runtime.turn_requests', ({ defer }) => {
const service = new TurnRequestService(createTurnRequestRepository(options.database), () => record({ event: 'observer.failed', component: 'runtime.turn_requests', level: 'warn' }), options.eventLog)
const service = new TurnRequestService(createTurnRequestRepository(options.database), () => record({ event: 'observer.failed', component: 'runtime.turn_requests', level: 'warn' }), conversationAvailability)
const notifications = service.onDidCommit((event) => {
const conversation = conversations.findById(event.conversationId)
if (conversation)
Expand Down Expand Up @@ -567,6 +583,13 @@ export async function startBuddyService(
runInputs,
runs,
repository: conversations,
spaces: spacesRepository,
spaceMoves: {
mutations: conversationMutations,
hasPendingMessages: id => chatQueue.hasPending(id),
hasActiveExecution: id => runner.hasActiveExecution(id) || runner.hasDegradedCleanup(id),
validateTarget: validateSpaceMoveTarget,
},
sessions: {
invalidateConversation: id => sessions.invalidateConversationWithResult(id),
applyPermissionSettings: conversation => sessions.applyConversationPermissions(conversation.id, conversation),
Expand Down Expand Up @@ -958,6 +981,18 @@ export async function startBuddyService(
spaces: spacesRepository,
skills: skillService,
})
async function validateSpaceMoveTarget(conversation: ConversationRecord, spaceId: string): Promise<void> {
if (!conversation.activeBranchId)
throw new BuddyServiceError('VALIDATION_FAILED')
await sessionBlueprints.createForConversation({
approvalPolicy: conversation.approvalPolicy,
branchId: conversation.activeBranchId,
conversationId: conversation.id,
executionProfile: conversation.executionProfile,
sessionMode: 'interactive',
spaceId,
})
}
const sessionRecovery = new BuddySessionRecoveryService({
usage: usageRepository,
attachments: attachmentService,
Expand Down Expand Up @@ -1006,7 +1041,7 @@ export async function startBuddyService(
}
const executionCoordinator = await host.start('runtime.execution_tree', ({ defer }) => {
const service = new ExecutionCoordinator({
availability: options.eventLog,
availability: conversationAvailability,
hostShellSupervision: currentPlatform.hostShellSupervision,
executions: executionRecords,
mailbox: createMailboxRepository(options.database),
Expand Down Expand Up @@ -1149,6 +1184,7 @@ export async function startBuddyService(
const service = new ConversationLifecycleService({
conversations,
deletion: createConversationDeletionRepository(options.database),
assertWritable: id => conversationMutations.assertWritable(id),
runner,
cancelQueuedRuns: async (conversationId) => {
try {
Expand Down Expand Up @@ -1246,7 +1282,7 @@ export async function startBuddyService(
})
const chatCommandService = await host.start('runtime.chat_commands', ({ defer }) => {
const service = new ChatCommandService({
availability: options.eventLog,
availability: conversationAvailability,
commands: commandRequests,
conversationLifecycle,
conversations,
Expand Down Expand Up @@ -1318,7 +1354,7 @@ export async function startBuddyService(
return service
})
chatQueueService = await host.start('runtime.chat-queue', async ({ defer }) => {
const service = new ChatQueueService({ availability: options.eventLog, queue: createChatQueueRepository(options.database), turns: chatTurnService, requests: turnRequests, launcher: turnLauncher, runner, runs, runInputs, getRunPermissionCeiling, eventLog: options.eventLog, onObserverError: () => record({ event: 'queue.observer_failed', level: 'error' }) })
const service = new ChatQueueService({ availability: conversationAvailability, queue: chatQueue, turns: chatTurnService, requests: turnRequests, launcher: turnLauncher, runner, runs, runInputs, getRunPermissionCeiling, eventLog: options.eventLog, onObserverError: () => record({ event: 'queue.observer_failed', level: 'error' }) })
const actionInputs = service.onDidChange((event) => {
if (event.committed?.messageId && event.committed.runId)
extensionActions.dispatch({ type: 'task:input:committed', data: { conversationId: event.scope.conversationId, branchId: event.scope.branchId, runId: event.committed.runId, messageId: event.committed.messageId, commitId: event.committed.commitId } })
Expand Down
Loading