diff --git a/desktop/src/workspace/local-workspace-agent-invocation.cjs b/desktop/src/workspace/local-workspace-agent-invocation.cjs index acef3d6..51e5550 100644 --- a/desktop/src/workspace/local-workspace-agent-invocation.cjs +++ b/desktop/src/workspace/local-workspace-agent-invocation.cjs @@ -11,7 +11,6 @@ const { nextSessionMeta, normalizeOutcomeRefs, normalizeSessionMeta, - shouldRotateSession, } = require('../runs/run-harness.cjs') const { canonicalJson, @@ -547,8 +546,13 @@ class LocalWorkspaceAgentInvocation { if (!taskId) throw new Error('LOCAL_RUN_TASK_INVALID') let workspacePath = group.workdir ? path.resolve(group.workdir) : group.id try { workspacePath = fs.realpathSync(workspacePath) } catch { /* runtime validates unavailable directories */ } + // Direct chats are independent execution surfaces. Scope their scheduler + // resource to the chat so one writable chat cannot block another Agent. + const schedulerWorkspaceKey = group.conversationType === 'direct' + ? `${workspacePath}\u0000direct:${group.id}` + : workspacePath const workspaceKey = createHash('sha256') - .update(String(workspacePath)) + .update(String(schedulerWorkspaceKey)) .digest('hex') const writerKind = cleanText(context.singleWriterKind || context.writerKind, 80) const permissionMode = group.allowWrite === true @@ -719,10 +723,9 @@ class LocalWorkspaceAgentInvocation { && !resumedConnectorGate ? context.resumedGate : null - const preserveGroupSession = group.conversationType !== 'direct' - const sessionNeedsRotation = sessionRef - && shouldRotateSession(sessionMeta) - && !preserveGroupSession + // A conversation owns one native session for its lifetime. Rebuild only + // when the Agent reports that the existing native session is invalid. + const sessionNeedsRotation = false if (resumedPermission) { const requestHash = sha256(canonicalJson(resumedPermission.request)) const persistedBinding = { @@ -1269,6 +1272,8 @@ class LocalWorkspaceAgentInvocation { ].join('\n') const buildPrompt = (afterKind, contextPackage) => isolated || frozen ? v4Prompt + : group.conversationType === 'direct' + ? contextPackage.currentTaskText : [ context.v4 === true && v4Prompt ? v4Prompt diff --git a/desktop/src/workspace/local-workspace-conversations.cjs b/desktop/src/workspace/local-workspace-conversations.cjs index b556f56..c68fd7c 100644 --- a/desktop/src/workspace/local-workspace-conversations.cjs +++ b/desktop/src/workspace/local-workspace-conversations.cjs @@ -23,6 +23,7 @@ class LocalWorkspaceConversations { this.isGroupBusy = options.isGroupBusy this.clearSessionState = options.clearSessionState this.runLedger = options.runLedger + this.discardAttachments = options.discardAttachments this.agentLabel = options.agentLabel this.createId = options.createId this.now = options.now @@ -152,11 +153,17 @@ class LocalWorkspaceConversations { if (!state.groups.some(group => group.id === groupId)) { throw new Error('LOCAL_GROUP_NOT_FOUND') } + const attachmentIds = [...new Set(state.messages + .filter(message => message.groupId === groupId) + .flatMap(message => Array.isArray(message.attachments) ? message.attachments : []) + .map(attachment => attachment?.id) + .filter(id => typeof id === 'string'))] this.commit(() => { this.clearSessionState(groupId) state.groups = state.groups.filter(group => group.id !== groupId) state.messages = state.messages.filter(message => message.groupId !== groupId) }, () => this.runLedger?.deleteGroup?.(groupId)) + this.discardAttachments?.(attachmentIds) } deleteMessage(groupId, messageId) { diff --git a/desktop/src/workspace/local-workspace.cjs b/desktop/src/workspace/local-workspace.cjs index 1c19070..616b3a0 100644 --- a/desktop/src/workspace/local-workspace.cjs +++ b/desktop/src/workspace/local-workspace.cjs @@ -217,6 +217,13 @@ class LocalWorkspace extends EventEmitter { }) this.runLedgerCoordinator = new LocalWorkspaceRunLedger({ runLedger: this.runLedger, + discardAttachments: ids => { + const referenced = new Set(this.state.messages.flatMap(message => ( + Array.isArray(message.attachments) ? message.attachments : [] + )).map(attachment => attachment?.id).filter(Boolean)) + const orphaned = ids.filter(id => !referenced.has(id)) + if (orphaned.length) this.contentBlobStore.discard(orphaned) + }, state: () => this.state, save: () => this.save(), createId: () => this.createId(), diff --git a/desktop/test/workspace/local-workspace-native-streaming.test.cjs b/desktop/test/workspace/local-workspace-native-streaming.test.cjs index ade1971..f180ddb 100644 --- a/desktop/test/workspace/local-workspace-native-streaming.test.cjs +++ b/desktop/test/workspace/local-workspace-native-streaming.test.cjs @@ -66,6 +66,32 @@ test('direct Pi conversations reuse the native JSON session across messages', as assert.deepEqual(calls.map(call => call.runOptions.sessionTransport), ['', '']) }) +test('direct conversations do not rotate their session after the default turn budget', async (t) => { + const { directory, calls, options } = fixture() + t.after(() => fs.rmSync(directory, { recursive: true, force: true })) + options.detectAgents = async () => [{ + kind: 'pi', name: 'Pi Agent', executable: '/tmp/pi', version: '0.84.2', + }] + options.runAgent = async (_agent, prompt, _workdir, runOptions) => { + calls.push({ prompt, runOptions }) + const sessionRef = runOptions.sessionRef || 'pi-long-session' + await runOptions.onSessionRef(sessionRef) + return { text: 'reply', sessionRef, outcome: 'completed' } + } + const workspace = new LocalWorkspace(options) + await workspace.refreshAgents() + const group = workspace.createGroup({ + name: 'Long direct', agentKinds: ['pi'], directAgentKind: 'pi', + conversationType: 'direct', workdir: directory, + }) + for (let index = 0; index < 20; index += 1) { + await workspace.sendMessage({ groupId: group.id, text: `message-${index}` }) + } + assert.equal(calls.length, 20) + assert.equal(calls.filter(call => call.runOptions.sessionRef === '').length, 1) + assert.equal(calls.slice(1).every(call => call.runOptions.sessionRef === 'pi-long-session'), true) +}) + test('group conversations dispatch Pi alongside other available Agents', async (t) => { const { directory, calls, options } = fixture() t.after(() => fs.rmSync(directory, { recursive: true, force: true })) diff --git a/desktop/test/workspace/local-workspace-write-scheduling.test.cjs b/desktop/test/workspace/local-workspace-write-scheduling.test.cjs index a7bd4c7..fa8eaa3 100644 --- a/desktop/test/workspace/local-workspace-write-scheduling.test.cjs +++ b/desktop/test/workspace/local-workspace-write-scheduling.test.cjs @@ -82,6 +82,35 @@ test('read-only group invocations retain parallel execution in the same director await Promise.all(runs) }) +test('writable direct chats do not block another Agent in the same directory', { timeout: 10000 }, async (t) => { + const { directory, options } = fixture() + const release = deferred() + const bothStarted = deferred() + t.after(() => { + release.resolve() + fs.rmSync(directory, { recursive: true, force: true }) + }) + options.runScheduler = new RunScheduler() + const calls = [] + options.runAgent = async (agent, _prompt, _cwd, runOptions) => { + calls.push({ kind: agent.kind, sandbox: runOptions.sandbox }) + if (calls.length === 2) bothStarted.resolve() + await release.promise + return { text: `${agent.kind} completed.` } + } + const workspace = new LocalWorkspace(options) + await workspace.refreshAgents() + const groups = ['codex', 'hermes'].map(kind => workspace.createGroup({ + name: `${kind} direct`, agentKinds: [kind], directAgentKind: kind, + conversationType: 'direct', workdir: directory, allowWrite: true, + })) + const runs = groups.map(group => workspace.sendMessage({ groupId: group.id, text: 'Run independently.' })) + await bothStarted.promise + assert.deepEqual(calls.map(call => call.sandbox), ['workspace-write', 'workspace-write']) + release.resolve() + await Promise.all(runs) +}) + test('Manual V4 retains parallel readers alongside its single writer and stable message order', { timeout: 10000 }, async (t) => { const { directory, options } = fixture() const release = deferred() diff --git a/desktop/test/workspace/local-workspace.test.cjs b/desktop/test/workspace/local-workspace.test.cjs index a8da1f9..33a7161 100644 --- a/desktop/test/workspace/local-workspace.test.cjs +++ b/desktop/test/workspace/local-workspace.test.cjs @@ -1399,6 +1399,7 @@ test('direct conversations force manual mode and reuse their group Agent session assert.equal(direct.directAgentKind, 'codex') assert.deepEqual(calls.map(call => call.agent.kind), ['codex', 'codex']) assert.deepEqual(calls.map(call => call.runOptions.sessionRef), ['', 'codex-session']) + assert.deepEqual(calls.map(call => call.prompt), ['第一条', '第二条']) assert.equal(calls.some(call => call.prompt.includes('MELDWORK_CONSENSUS')), false) assert.equal(workspace.snapshot().messages.some(message => message.threadRootId), false) const restored = new LocalWorkspace(options)