Quellcode durchsuchen

Add conversation task support to workspace chats

LSamien vor 1 Monat
Ursprung
Commit
93afe64777

+ 1 - 1
src/server/app.ts

@@ -208,7 +208,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
   workspaceRuntime.onMessage = (owner, message) => workspaceAssets.archive(owner, message)
   workspaceRuntime.onNotify = (owner, c, run, message, interaction) => {
     if (c.kind === 'group' && !push.isGroupSubscribed(owner,c.id)) return
-    push.enqueueNotification({ kind: interaction ? interaction.kind === 'approval' ? 'chat.approval.requested' : 'chat.clarification.requested' : run.status === 'failed' ? 'chat.failed' : 'chat.completed', eventID: `workspace:${interaction?.id ?? run.id}`, localUserID: owner, title: c.name, body: (interaction?.message || message?.content || run.error || '回复完成').slice(0,180), collapseID: `workspace:${c.id}`, data: { conversationId:c.id, workspace:'1' }, ...(c.kind === 'group' ? {roomID:c.id} : {}) })
+    push.enqueueNotification({ kind: interaction ? interaction.kind === 'approval' ? 'chat.approval.requested' : 'chat.clarification.requested' : run.status === 'failed' ? 'chat.failed' : 'chat.completed', eventID: `workspace:${interaction?.id ?? run.id}`, localUserID: owner, title: c.name, body: (interaction?.message || message?.content || run.error || '回复完成').slice(0,180), collapseID: `workspace:${c.id}:${run.conversationTaskId ?? 'direct'}`, data: { conversationId:c.id, ...(run.conversationTaskId ? { conversationTaskId: run.conversationTaskId } : {}), workspace:'1' }, ...(c.kind === 'group' ? {roomID:c.id} : {}) })
   }
   const realtime = new RealtimeAPI(config, auth, csrf, pairings, upstream, upstreamSession, {
     coordinator: pushEventCoordinator,

+ 52 - 9
src/server/workspaceRoutes.ts

@@ -59,6 +59,7 @@ export function workspaceRouter(
       features: [
         'agents',
         'conversations',
+        'conversationTasks',
         'editableGroups',
         'files',
         'voice',
@@ -112,20 +113,49 @@ export function workspaceRouter(
     ctx.body = { conversation }
     ctx.status = 201
   })
+  router.get('/api/app/conversations/:id/tasks', (ctx) => {
+    const user = owner(ctx)
+    store.resolveTask(user, ctx.params.id)
+    ctx.body = { tasks: store.tasks(user, ctx.params.id) }
+  })
+  router.post('/api/app/conversations/:id/tasks', (ctx) => {
+    const task = store.createTask(owner(ctx), ctx.params.id, body(ctx))
+    ctx.status = 201
+    ctx.body = { task }
+  })
+  router.patch('/api/app/conversations/:id/tasks/:taskId', (ctx) => {
+    ctx.body = { task: store.updateTask(owner(ctx), ctx.params.id, ctx.params.taskId, body(ctx)) }
+  })
+  router.put('/api/app/conversations/:id/tasks/:taskId/read', (ctx) => {
+    const seq = parse(z.object({ seq: z.number().int().nonnegative() }).strict(), body(ctx)).seq
+    ctx.body = { task: store.markTaskRead(owner(ctx), ctx.params.id, ctx.params.taskId, seq) }
+  })
+  router.delete('/api/app/conversations/:id/tasks/:taskId', async (ctx) => {
+    const user = owner(ctx)
+    await runtime.stopTask(user, ctx.params.id, ctx.params.taskId)
+    store.deleteTask(user, ctx.params.id, ctx.params.taskId)
+    ctx.body = { ok: true }
+  })
   router.get('/api/app/conversations/:id', (ctx) => {
     const user = owner(ctx),
-      conversation = store.require<WorkspaceConversation>(user, 'conversation', ctx.params.id)
+      conversation = store.require<WorkspaceConversation>(user, 'conversation', ctx.params.id),
+      requestedTaskId = typeof ctx.query.taskId === 'string' ? ctx.query.taskId : undefined,
+      task = store.resolveTask(user, conversation.id, requestedTaskId),
+      tasks = conversation.kind === 'group' ? store.tasks(user, conversation.id) : [],
+      activeRunId = task?.activeRunId ?? (conversation.kind === 'direct' ? conversation.activeRunId : undefined)
     ctx.body = {
       conversation: store.conversationSummary(user, conversation),
-      messages: store.messages(user, conversation.id),
-      hiddenMessageIds: store.hiddenMessageIds(user, conversation.id),
-      run: conversation.activeRunId
-        ? store.require<WorkspaceRun>(user, 'run', conversation.activeRunId)
+      task: task ?? null,
+      tasks,
+      messages: store.messages(user, conversation.id, Number.MAX_SAFE_INTEGER, 100, false, task?.id),
+      hiddenMessageIds: store.hiddenMessageIds(user, conversation.id, task?.id),
+      run: activeRunId
+        ? store.require<WorkspaceRun>(user, 'run', activeRunId)
         : null,
       interactions: store
         .list<WorkspaceInteraction>(user, 'interaction')
-        .filter((i) => i.conversationId === conversation.id && !i.resolved),
-      context: store.get(user, 'context', conversation.id) ?? null,
+        .filter((i) => i.conversationId === conversation.id && i.conversationTaskId === task?.id && !i.resolved),
+      context: store.get(user, 'context', task?.id ?? conversation.id) ?? null,
     }
   })
   router.patch('/api/app/conversations/:id', async (ctx) => {
@@ -135,16 +165,24 @@ export function workspaceRouter(
     ctx.body = { conversation: store.require(user, 'conversation', conversation.id) }
   })
   router.post('/api/app/conversations/:id/agents/:agentId/stop', async (ctx) => {
-    await runtime.stopAgent(owner(ctx), ctx.params.id, ctx.params.agentId)
+    const user = owner(ctx),
+      taskId = typeof ctx.query.taskId === 'string' ? ctx.query.taskId : undefined,
+      task = store.resolveTask(user, ctx.params.id, taskId)
+    await runtime.stopAgent(user, ctx.params.id, ctx.params.agentId, task?.id)
     ctx.body = { ok: true }
   })
   router.get('/api/app/conversations/:id/messages', (ctx) => {
+    const user = owner(ctx),
+      taskId = typeof ctx.query.taskId === 'string' ? ctx.query.taskId : undefined,
+      task = store.resolveTask(user, ctx.params.id, taskId)
     ctx.body = {
       messages: store.messages(
-        owner(ctx),
+        user,
         ctx.params.id,
         number(ctx.query.before, Number.MAX_SAFE_INTEGER),
         Math.min(200, number(ctx.query.limit, 100)),
+        false,
+        task?.id,
       ),
     }
   })
@@ -157,6 +195,11 @@ export function workspaceRouter(
     const user = owner(ctx),
       c = store.require<WorkspaceConversation>(user, 'conversation', ctx.params.id)
     const seq = parse(z.object({ seq: z.number().int().nonnegative() }).strict(), body(ctx)).seq
+    if (c.kind === 'group') {
+      const taskId = typeof ctx.query.taskId === 'string' ? ctx.query.taskId : undefined,
+        task = store.resolveTask(user, c.id, taskId)!
+      store.markTaskRead(user, c.id, task.id, seq)
+    }
     c.readSeq = Math.max(c.readSeq, Math.min(seq, c.lastSeq))
     store.atomic(() => {
       store.put(user, 'conversation', c.id, c)

+ 31 - 12
src/server/workspaceRuntime.ts

@@ -27,6 +27,7 @@ export interface WorkspaceBinding {
   aliases: string[]
   runId: string
   messageId: string
+  conversationTaskId?: string
   taskId?: string
   contextSeq?: number
 }
@@ -35,6 +36,7 @@ interface LiveTurn {
   runtimeId: string
   runId: string
   agentId: string
+  conversationTaskId?: string
   taskId: string
   done(error?: Error): void
 }
@@ -42,6 +44,7 @@ export const sendInput = z
   .object({
     requestId: z.string().uuid(),
     content: z.string().max(65_536).default(''),
+    taskId: z.string().uuid().optional(),
     mentionIds: z.array(z.string().uuid()).max(8).default([]),
     fileIds: z.array(z.string().uuid()).max(8).default([]),
   })
@@ -55,6 +58,13 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     const result = this.store.command(owner, body.requestId, { conversationId, ...body }, () => {
       const c = this.store.require<Conversation>(owner, 'conversation', conversationId)
       if (c.archived) throw new HttpError(409, '聊天已归档', 'conversation_archived')
+      const conversationTask = this.store.resolveTask(owner, conversationId, body.taskId),
+        conversationTaskId = conversationTask?.id
+      if (conversationTaskId) {
+        const alreadyActive = this.store.list<Run>(owner, 'run').some(run => run.conversationTaskId === conversationTaskId && !['complete', 'failed', 'interrupted'].includes(run.status))
+        if (!alreadyActive && this.store.activeTaskCount(owner, conversationId) >= 4)
+          throw new HttpError(409, '最多同时运行 4 个任务', 'workspace_task_concurrency_limit')
+      }
       if (body.mentionIds.some((a) => !c.memberIds.includes(a)))
         throw new HttpError(400, '只能 @ 群内成员', 'invalid_mentions')
       const records = this.uploads.records(body.fileIds, owner)
@@ -69,6 +79,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       const message: Message = {
         id: randomUUID(),
         conversationId,
+        conversationTaskId,
         seq: 0,
         role: 'user',
         content: body.content.trim(),
@@ -99,6 +110,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       const run: Run = {
         id: runId,
         conversationId,
+        conversationTaskId,
         messageId: message.id,
         mentionIds: mentions,
         activeAgentId: c.administratorId,
@@ -121,7 +133,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     run: Work,
     recovering = false,
   ): Promise<Message> {
-    const key = `${c.id}:${agent.id}`,
+    const key = this.bindingKey(c.id, run.conversationTaskId, agent.id),
       target = this.nodes.target(owner, agent.nodeId)
     const gateway = new WorkspaceGateway(target)
     let binding = this.store.get<WorkspaceBinding>(owner, 'binding', key)
@@ -133,6 +145,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       message = {
         id: randomUUID(),
         conversationId: c.id,
+        conversationTaskId: run.conversationTaskId,
         seq: 0,
         role: 'assistant',
         agentId: agent.id,
@@ -247,6 +260,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         const interaction: WorkspaceInteraction = previous ?? {
           id: randomUUID(),
           conversationId: c.id,
+          conversationTaskId: run.conversationTaskId,
           runId: run.runId,
           agentId: agent.id,
           kind: type.startsWith('approval') ? 'approval' : 'clarification',
@@ -255,7 +269,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           resolved: false,
         }
         this.store.put(owner, 'interaction', interaction.id, interaction)
-        this.store.put(owner, 'interaction-binding', interaction.id, { key, upstreamId, taskId: run.id })
+        this.store.put(owner, 'interaction-binding', interaction.id, { key, upstreamId, taskId: run.id, conversationTaskId: run.conversationTaskId })
         this.store.event(owner, 'interaction.changed', interaction, c.id)
         const current = this.getWork(owner, run.id)
         current.hadInteraction = true
@@ -287,19 +301,22 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
               if (usage && !this.closing) {
                 const used = usage.context_used ?? usage.used_tokens,
                   limit = usage.context_max ?? usage.context_limit
-                this.store.put(owner, 'context', c.id, {
+                const contextKey = run.conversationTaskId ?? c.id,
+                  context = {
                   ...usage,
+                  conversationTaskId: run.conversationTaskId,
                   usedTokens: used,
                   limitTokens: limit,
                   percent:
                     typeof used === 'number' && typeof limit === 'number' && limit > 0
                       ? Math.round((used / limit) * 1000) / 10
                       : undefined,
-                })
+                  }
+                this.store.put(owner, 'context', contextKey, context)
                 this.store.event(
                   owner,
                   'context.changed',
-                  this.store.get(owner, 'context', c.id),
+                  context,
                   c.id,
                 )
               }
@@ -316,8 +333,9 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         finish(new HttpError(502, String(p.message ?? p.error ?? '运行失败'), 'run_failed'))
         return
       } else if (['session.usage', 'usage.update', 'context.update'].includes(type)) {
-        this.store.put(owner, 'context', c.id, p)
-        this.store.event(owner, 'context.changed', p, c.id)
+        const context = { ...p, conversationTaskId: run.conversationTaskId }
+        this.store.put(owner, 'context', run.conversationTaskId ?? c.id, context)
+        this.store.event(owner, 'context.changed', context, c.id)
       } else if (type === 'session.info' && binding) {
         const storedId = p.stored_session_id ?? p.session_key
         if (typeof storedId === 'string' && storedId && storedId !== binding.storedId) {
@@ -342,7 +360,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           })
         : await gateway.rpc('session.create', {
             profile: agent.profile,
-            title: `Yaoyao ${c.id}`,
+            title: `Yaoyao ${c.id}${run.conversationTaskId ? ` / ${run.conversationTaskId}` : ''}`,
             source: 'yaoyao_workspace',
             hidden: true,
             room_plumbing: true,
@@ -372,12 +390,13 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           ]),
         ],
         runId: run.runId,
+        conversationTaskId: run.conversationTaskId,
         taskId: run.id,
         contextSeq: binding?.contextSeq ?? 0,
         messageId: resultMessage.id,
       }
       this.store.put(owner, 'binding', key, binding)
-      this.live.set(key, { gateway, runtimeId, runId: run.runId, taskId: run.id, agentId: agent.id, done: finish })
+      this.live.set(key, { gateway, runtimeId, runId: run.runId, conversationTaskId: run.conversationTaskId, taskId: run.id, agentId: agent.id, done: finish })
       if (this.closing || this.getWork(owner, run.id).cancelRequested || this.getWork(owner, run.id).status === 'interrupted')
         throw new Error('运行已停止')
       if (recovering) {
@@ -487,8 +506,8 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
   }
   private contextText(owner: string, c: Conversation, agent: Agent, work: Work, after: number): string {
     const rootTrigger = this.store.require<Message>(owner, 'message', work.messageId)
-    const roots = new Map(this.store.list<Run>(owner, 'run').filter(r => r.conversationId === c.id).map(r => [r.id, this.store.get<Message>(owner, 'message', r.messageId)?.seq ?? 0]))
-    const history = this.store.messages(owner, c.id, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, true)
+    const roots = new Map(this.store.list<Run>(owner, 'run').filter(r => r.conversationId === c.id && r.conversationTaskId === work.conversationTaskId).map(r => [r.id, this.store.get<Message>(owner, 'message', r.messageId)?.seq ?? 0]))
+    const history = this.store.messages(owner, c.id, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, true, work.conversationTaskId)
     const unfinished = history.find(m => m.seq > after && m.seq <= work.triggerSeq && (['queued', 'streaming', 'uncertain'].includes(m.status) || (m.runId && (roots.get(m.runId) ?? 0) > rootTrigger.seq)))
     work.contextThroughSeq = unfinished ? unfinished.seq - 1 : work.triggerSeq
     const eligible = history
@@ -554,7 +573,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     if (current.status === 'waiting') { current.status = 'running'; this.saveWork(owner, current) }
   }
   protected async interruptTurn(owner: string, work: Work): Promise<void> {
-    const key = `${work.conversationId}:${work.agentId}`
+    const key = this.bindingKey(work.conversationId, work.conversationTaskId, work.agentId)
     const live = this.live.get(key)
     if (live?.taskId === work.id) {
       await live.gateway.rpc('session.interrupt', { session_id: live.runtimeId })

+ 24 - 13
src/server/workspaceScheduler.ts

@@ -11,6 +11,7 @@ export interface Work {
   id: string
   runId: string
   conversationId: string
+  conversationTaskId?: string
   agentId: string
   messageId: string
   triggerSeq: number
@@ -64,7 +65,7 @@ export abstract class WorkspaceScheduler {
           if (index === 0 && root.currentMessageId) {
             Object.assign(work, { currentMessageId: root.currentMessageId, submitted: true, status: 'uncertain', turnConfiguration: root.turnConfiguration })
             store.put(owner, 'turn', work.id, work)
-            const key = `${c.id}:${id}`
+            const key = this.bindingKey(c.id, root.conversationTaskId, id)
             const binding = store.get<{ runId: string; messageId: string; taskId?: string }>(owner, 'binding', key)
             if (binding?.runId === root.id && binding.messageId === root.currentMessageId) {
               binding.taskId = work.id; store.put(owner, 'binding', key, binding)
@@ -86,6 +87,9 @@ export abstract class WorkspaceScheduler {
   }
   protected abstract performTurn(owner: string, c: Conversation, agent: Agent, work: Work, recovering: boolean): Promise<Message>
   protected abstract interruptTurn(owner: string, work: Work): Promise<void>
+  protected bindingKey(conversationId: string, conversationTaskId: string | undefined, agentId: string): string {
+    return conversationTaskId ? `${conversationId}:${conversationTaskId}:${agentId}` : `${conversationId}:${agentId}`
+  }
   protected works(owner: string, runId?: string): Work[] {
     return this.store.db.prepare(`SELECT data FROM workspace_entities WHERE owner=? AND kind='turn' ${runId ? "AND json_extract(data,'$.runId')=?" : ''}`).all(...(runId ? [owner, runId] : [owner]))
       .map(row => JSON.parse(String(row.data)) as Work)
@@ -101,7 +105,7 @@ export abstract class WorkspaceScheduler {
   protected enqueue(owner: string, root: Run, agentId: string, depth: number, batchId: string, triggerSeq: number, replyMode: Work['replyMode'], requiredReply: boolean, reviewOf?: string): Work {
     const existing = this.works(owner, root.id).find(w => reviewOf ? w.reviewOf === reviewOf : w.agentId === agentId && w.depth === depth && !w.reviewOf)
     if (existing) return existing
-    const work: Work = { id: randomUUID(), runId: root.id, conversationId: root.conversationId, agentId, messageId: root.messageId, depth, batchId, triggerSeq, replyMode, requiredReply, reviewOf, status: 'queued', createdAt: Date.now() }
+    const work: Work = { id: randomUUID(), runId: root.id, conversationId: root.conversationId, conversationTaskId: root.conversationTaskId, agentId, messageId: root.messageId, depth, batchId, triggerSeq, replyMode, requiredReply, reviewOf, status: 'queued', createdAt: Date.now() }
     this.store.put(owner, 'turn', work.id, work)
     return work
   }
@@ -142,8 +146,8 @@ export abstract class WorkspaceScheduler {
     this.store.saveRun(owner, root)
     if (done && !wasTerminal && root.status !== 'interrupted') {
       const c = this.store.require<Conversation>(owner, 'conversation', root.conversationId)
-      if (root.status === 'failed' && c.kind === 'direct') this.store.saveMessage(owner, { id: randomUUID(), conversationId: c.id, seq: 0, role: 'system', content: `执行失败:${root.error ?? '运行失败'}`, reasoning: '', status: 'failed', runId: root.id, attachments: [], tools: [], createdAt: Date.now() })
-      this.notify(owner, c, root, this.store.messages(owner, c.id).filter(m => m.runId === id && m.visible !== false).at(-1))
+      if (root.status === 'failed' && c.kind === 'direct') this.store.saveMessage(owner, { id: randomUUID(), conversationId: c.id, conversationTaskId: root.conversationTaskId, seq: 0, role: 'system', content: `执行失败:${root.error ?? '运行失败'}`, reasoning: '', status: 'failed', runId: root.id, attachments: [], tools: [], createdAt: Date.now() })
+      this.notify(owner, c, root, this.store.messages(owner, c.id, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, false, root.conversationTaskId).filter(m => m.runId === id && m.visible !== false).at(-1))
     }
   }
   private plan(owner: string, id: string): void {
@@ -170,7 +174,7 @@ export abstract class WorkspaceScheduler {
           for (const target of targets) this.enqueue(owner, root, target, nextDepth, work.id, message.seq, mentions.includes(target) ? 'mentioned' : 'automatic', false)
         } else if (targets.length && !this.store.get(owner, 'limit-notice', root.id)) {
           this.store.put(owner, 'limit-notice', root.id, { id: root.id })
-          this.store.saveMessage(owner, { id: randomUUID(), conversationId: c.id, seq: 0, role: 'system', content: '已达到自动协作轮数上限,本轮不再自动分派。', reasoning: '', status: 'complete', runId: root.id, attachments: [], tools: [], createdAt: Date.now() })
+          this.store.saveMessage(owner, { id: randomUUID(), conversationId: c.id, conversationTaskId: root.conversationTaskId, seq: 0, role: 'system', content: '已达到自动协作轮数上限,本轮不再自动分派。', reasoning: '', status: 'complete', runId: root.id, attachments: [], tools: [], createdAt: Date.now() })
         }
       }
       this.updateRoot(owner, root.id)
@@ -191,7 +195,7 @@ export abstract class WorkspaceScheduler {
       all = this.store.owners().flatMap(owner => this.pendingWorks(owner).map(work => ({ owner, work })))
       all.sort((a, b) => {
         const am = this.store.require<Message>(a.owner, 'message', a.work.messageId), bm = this.store.require<Message>(b.owner, 'message', b.work.messageId)
-        return am.createdAt - bm.createdAt || (a.work.conversationId === b.work.conversationId ? am.seq - bm.seq : 0) || a.work.createdAt - b.work.createdAt || a.work.id.localeCompare(b.work.id)
+        return am.createdAt - bm.createdAt || (a.work.conversationId === b.work.conversationId && a.work.conversationTaskId === b.work.conversationTaskId ? am.seq - bm.seq : 0) || a.work.createdAt - b.work.createdAt || a.work.id.localeCompare(b.work.id)
       })
       for (const { owner, work: candidate } of all) {
         const work = this.getWork(owner, candidate.id)
@@ -223,12 +227,12 @@ export abstract class WorkspaceScheduler {
           work.status = 'interrupted'; work.error = '执行前成员已移除或聊天已停止'; this.saveWork(owner, work); this.wake(); continue
         }
         const occupied = all.map(entry => this.getWork(entry.owner, entry.work.id)).filter(t => (['running', 'waiting', 'uncertain'].includes(t.status) || this.executing.has(t.id)) && t.id !== work.id)
-        if (occupied.some(t => t.conversationId === c.id && t.agentId === work.agentId)) continue
-        const inRoom = occupied.filter(t => t.conversationId === c.id)
-        if (work.status === 'queued' && (occupied.length >= 4 || inRoom.length >= 3)) continue
+        if (occupied.some(t => t.conversationId === c.id && t.conversationTaskId === work.conversationTaskId && t.agentId === work.agentId)) continue
+        const inTask = occupied.filter(t => t.conversationId === c.id && t.conversationTaskId === work.conversationTaskId)
+        if (work.status === 'queued' && (occupied.length >= 4 || inTask.length >= 3)) continue
         if (work.status === 'uncertain' && (this.recoverAfter.get(work.id) ?? 0) > Date.now()) { this.retrySoon(); continue }
         if (c.mode === 'host' || c.kind === 'direct') {
-          if (inRoom.some(t => c.kind === 'direct' || t.runId !== work.runId || t.batchId !== work.batchId || t.agentId === c.administratorId || work.agentId === c.administratorId)) continue
+          if (inTask.some(t => c.kind === 'direct' || t.runId !== work.runId || t.batchId !== work.batchId || t.agentId === c.administratorId || work.agentId === c.administratorId)) continue
         }
         this.executing.add(work.id)
         const recovering = work.status === 'uncertain'
@@ -261,7 +265,7 @@ export abstract class WorkspaceScheduler {
       }
       if (current.status === 'failed' && !current.currentMessageId) {
         current.currentMessageId = randomUUID()
-        this.store.saveMessage(owner, { id: current.currentMessageId, conversationId: current.conversationId, seq: 0, role: 'assistant', agentId: current.agentId, agentName: this.store.get<Agent>(owner, 'agent', current.agentId)?.name, content: `执行失败:${current.error}`, reasoning: '', status: 'failed', error: current.error, taskId: current.id, runId: current.runId, attachments: [], tools: [], createdAt: Date.now() })
+        this.store.saveMessage(owner, { id: current.currentMessageId, conversationId: current.conversationId, conversationTaskId: current.conversationTaskId, seq: 0, role: 'assistant', agentId: current.agentId, agentName: this.store.get<Agent>(owner, 'agent', current.agentId)?.name, content: `执行失败:${current.error}`, reasoning: '', status: 'failed', error: current.error, taskId: current.id, runId: current.runId, attachments: [], tools: [], createdAt: Date.now() })
         this.saveWork(owner, current)
       }
     } finally {
@@ -290,10 +294,17 @@ export abstract class WorkspaceScheduler {
     this.store.saveRun(owner, root)
     await this.cancelWorks(owner, this.works(owner, id))
   }
-  async stopAgent(owner: string, conversationId: string, agentId: string): Promise<void> {
+  async stopAgent(owner: string, conversationId: string, agentId: string, conversationTaskId?: string): Promise<void> {
     const c = this.store.require<Conversation>(owner, 'conversation', conversationId)
     if (!c.memberIds.includes(agentId)) throw new HttpError(400, '只能停止群内成员', 'invalid_members')
-    await this.cancelWorks(owner, this.works(owner).filter(w => w.conversationId === conversationId && w.agentId === agentId))
+    const scopedTaskId = c.kind === 'group' ? this.store.resolveTask(owner, conversationId, conversationTaskId)!.id : undefined
+    await this.cancelWorks(owner, this.works(owner).filter(w => w.conversationId === conversationId && w.agentId === agentId && w.conversationTaskId === scopedTaskId))
+  }
+  async stopTask(owner: string, conversationId: string, conversationTaskId: string): Promise<void> {
+    this.store.requireTask(owner, conversationId, conversationTaskId)
+    const roots = this.store.list<Run>(owner, 'run').filter(r => r.conversationId === conversationId && r.conversationTaskId === conversationTaskId && !terminal(r.status))
+    this.store.atomic(() => { for (const root of roots) { root.stopRequested = true; this.store.saveRun(owner, root) } })
+    await this.cancelWorks(owner, this.works(owner).filter(w => w.conversationId === conversationId && w.conversationTaskId === conversationTaskId))
   }
   async stopConversation(owner: string, id: string): Promise<void> {
     this.store.require<Conversation>(owner, 'conversation', id)

+ 183 - 20
src/server/workspaceStore.ts

@@ -10,6 +10,7 @@ import { decodeAgentMascotAvatar, isAgentImageAvatar, defaultAgentIdentity, enco
 import type {
   WorkspaceAgent as Agent,
   WorkspaceConversation as Conversation,
+  WorkspaceTask as Task,
   WorkspaceMessage as Message,
   WorkspaceRun as Run,
   WorkspaceEvent,
@@ -28,6 +29,7 @@ const avatar = z
     (v) => !v || isAgentImageAvatar(v) || !!decodeAgentMascotAvatar(v) || /^builtin:team-animal:(fox|whale|owl|rabbit|bear)$/.test(v),
     '请选择有效的内置头像或 PNG、JPEG、WebP 图片',
   )
+const taskTitle = z.string().trim().min(1).max(100).refine((v) => !/[\u0000-\u001f]/.test(v), '标题不能包含控制字符')
 export const agentInput = z
   .object({
     name,
@@ -77,6 +79,12 @@ export const conversationPatch = z
     pinned: z.boolean().optional(),
   })
   .strict()
+export const taskInput = z
+  .object({ title: z.string().trim().max(100).refine((v) => !/[\u0000-\u001f]/.test(v), '标题不能包含控制字符').optional() })
+  .strict()
+export const taskPatch = z
+  .object({ title: taskTitle })
+  .strict()
 export function parse<T>(schema: z.ZodType<T>, value: unknown): T {
   const parsed = schema.safeParse(value)
   if (!parsed.success)
@@ -103,7 +111,10 @@ export class WorkspaceStore {
       CREATE TABLE IF NOT EXISTS workspace_commands(owner TEXT NOT NULL,id TEXT NOT NULL,fingerprint TEXT NOT NULL,result TEXT NOT NULL,PRIMARY KEY(owner,id));`)
     this.db.exec(`CREATE INDEX IF NOT EXISTS workspace_turn_run ON workspace_entities(owner, json_extract(data,'$.runId')) WHERE kind='turn';
       CREATE INDEX IF NOT EXISTS workspace_turn_status ON workspace_entities(owner, json_extract(data,'$.status'), json_extract(data,'$.planned')) WHERE kind='turn';
-      CREATE INDEX IF NOT EXISTS workspace_turn_conversation ON workspace_entities(owner, json_extract(data,'$.conversationId')) WHERE kind='turn';`)
+      CREATE INDEX IF NOT EXISTS workspace_turn_conversation ON workspace_entities(owner, json_extract(data,'$.conversationId')) WHERE kind='turn';
+      CREATE INDEX IF NOT EXISTS workspace_message_task ON workspace_entities(owner, json_extract(data,'$.conversationTaskId'), json_extract(data,'$.seq')) WHERE kind='message';
+      CREATE INDEX IF NOT EXISTS workspace_run_task ON workspace_entities(owner, json_extract(data,'$.conversationTaskId'), json_extract(data,'$.status')) WHERE kind='run';
+      CREATE INDEX IF NOT EXISTS workspace_turn_task ON workspace_entities(owner, json_extract(data,'$.conversationTaskId'), json_extract(data,'$.status')) WHERE kind='turn';`)
     this.changes.setMaxListeners(0)
     this.atomic(() => {
       this.db.exec('CREATE TABLE IF NOT EXISTS workspace_migrations(id TEXT PRIMARY KEY)')
@@ -340,9 +351,108 @@ export class WorkspaceStore {
     this.atomic(() => {
       this.put(owner, 'conversation', c.id, c)
       this.event(owner, 'conversation.changed', c, c.id)
+      this.createTask(owner, c.id, {})
     })
     return c
   }
+  tasks(owner: string, conversationId: string): Task[] {
+    const conversation = this.require<Conversation>(owner, 'conversation', conversationId)
+    if (conversation.kind !== 'group')
+      throw new HttpError(400, '单聊不支持任务', 'conversation_tasks_unsupported')
+    return this.list<Task>(owner, 'conversation-task')
+      .filter((task) => task.conversationId === conversationId)
+      .sort((a, b) => b.updatedAt - a.updatedAt || b.createdAt - a.createdAt || a.id.localeCompare(b.id))
+  }
+  createTask(owner: string, conversationId: string, input: unknown): Task {
+    const body = parse(taskInput, input),
+      conversation = this.require<Conversation>(owner, 'conversation', conversationId)
+    if (conversation.kind !== 'group')
+      throw new HttpError(400, '单聊不支持任务', 'conversation_tasks_unsupported')
+    const now = Date.now(),
+      explicitTitle = body.title?.trim(),
+      task: Task = {
+        id: randomUUID(),
+        conversationId,
+        title: explicitTitle || '新任务',
+        titleSource: explicitTitle ? 'user' : 'automatic',
+        messageCount: 0,
+        readSeq: 0,
+        lastSeq: 0,
+        unreadCount: 0,
+        createdAt: now,
+        updatedAt: now,
+      }
+    this.atomic(() => {
+      this.put(owner, 'conversation-task', task.id, task)
+      this.event(owner, 'task.changed', task, conversationId)
+    })
+    return task
+  }
+  requireTask(owner: string, conversationId: string, taskId: string): Task {
+    const task = this.require<Task>(owner, 'conversation-task', taskId)
+    if (task.conversationId !== conversationId)
+      throw new HttpError(404, '任务不存在', 'workspace_task_not_found')
+    return task
+  }
+  resolveTask(owner: string, conversationId: string, taskId?: string): Task | undefined {
+    const conversation = this.require<Conversation>(owner, 'conversation', conversationId)
+    if (conversation.kind === 'direct') {
+      if (taskId) throw new HttpError(400, '单聊不支持任务', 'conversation_tasks_unsupported')
+      return undefined
+    }
+    if (taskId) return this.requireTask(owner, conversationId, taskId)
+    return this.tasks(owner, conversationId)[0] ?? this.createTask(owner, conversationId, {})
+  }
+  updateTask(owner: string, conversationId: string, taskId: string, input: unknown): Task {
+    const patch = parse(taskPatch, input)
+    return this.atomic(() => {
+      const task = this.requireTask(owner, conversationId, taskId),
+        next: Task = { ...task, title: patch.title, titleSource: 'user', updatedAt: Date.now() }
+      this.put(owner, 'conversation-task', task.id, next)
+      this.event(owner, 'task.changed', next, conversationId)
+      return next
+    })
+  }
+  markTaskRead(owner: string, conversationId: string, taskId: string, seq: number): Task {
+    return this.atomic(() => {
+      const task = this.requireTask(owner, conversationId, taskId)
+      task.readSeq = Math.max(task.readSeq, Math.min(seq, task.lastSeq))
+      task.unreadCount = this.messages(owner, conversationId, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, false, task.id)
+        .filter(message => message.seq > task.readSeq).length
+      this.put(owner, 'conversation-task', task.id, task)
+      this.event(owner, 'task.changed', task, conversationId)
+      return task
+    })
+  }
+  deleteTask(owner: string, conversationId: string, taskId: string): void {
+    this.atomic(() => {
+      const task = this.requireTask(owner, conversationId, taskId)
+      if (this.list<Run>(owner, 'run').some(run => run.conversationTaskId === taskId && !['complete', 'failed', 'interrupted'].includes(run.status)))
+        throw new HttpError(409, '任务仍在运行,暂时无法移除', 'workspace_task_running')
+      const messageIds = new Set(this.list<Message>(owner, 'message').filter(message => message.conversationTaskId === taskId).map(message => message.id))
+      this.db.prepare("DELETE FROM workspace_entities WHERE owner=? AND kind IN ('message','run','turn','interaction','interaction-binding','binding') AND json_extract(data,'$.conversationTaskId')=?").run(owner, taskId)
+      this.remove(owner, 'context', taskId)
+      this.remove(owner, 'conversation-task', taskId)
+      this.db.prepare("DELETE FROM workspace_commands WHERE owner=? AND json_extract(result,'$.conversationTaskId')=?").run(owner, taskId)
+      for (const file of this.list<Record<string, unknown> & { id: string; messageId?: string }>(owner, 'file')) {
+        if (!file.messageId || !messageIds.has(file.messageId)) continue
+        this.put(owner, 'file', String(file.id), { ...file, conversationId: undefined, messageId: undefined })
+      }
+      this.event(owner, 'task.deleted', { id: taskId, conversationId }, conversationId)
+      const remaining = this.tasks(owner, conversationId)
+      if (!remaining.length) this.createTask(owner, conversationId, {})
+      const conversation = this.require<Conversation>(owner, 'conversation', conversationId),
+        history = this.messages(owner, conversationId, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER)
+      const last = history.at(-1)
+      conversation.preview = last ? notificationPlainText(last.content, { maximum: 160, fallback: '' }) : ''
+      conversation.previewAgentId = last?.role === 'assistant' ? last.agentId : undefined
+      conversation.lastMessageAt = last?.createdAt
+      conversation.updatedAt = Date.now()
+      this.projectConversationRuns(owner, conversation)
+      this.put(owner, 'conversation', conversationId, conversation)
+      this.event(owner, 'conversation.changed', conversation, conversationId)
+    })
+  }
   updateConversation(owner: string, id: string, input: unknown): Conversation {
     const patch = parse(conversationPatch, input),
       c = this.require<Conversation>(owner, 'conversation', id)
@@ -382,13 +492,16 @@ export class WorkspaceStore {
     before = Number.MAX_SAFE_INTEGER,
     limit = 100,
     includeHidden = false,
+    conversationTaskId?: string,
   ): Message[] {
     this.require(owner, 'conversation', conversationId)
+    if (conversationTaskId) this.requireTask(owner, conversationId, conversationTaskId)
+    const taskClause = conversationTaskId ? "AND json_extract(data,'$.conversationTaskId')=?" : ''
     return this.db
       .prepare(
-        `SELECT data FROM workspace_entities WHERE owner=? AND kind='message' AND json_extract(data,'$.conversationId')=? AND json_extract(data,'$.seq')<? ${includeHidden ? '' : "AND coalesce(json_extract(data,'$.visible'),1) != 0"} ORDER BY json_extract(data,'$.seq') DESC LIMIT ?`,
+        `SELECT data FROM workspace_entities WHERE owner=? AND kind='message' AND json_extract(data,'$.conversationId')=? ${taskClause} AND json_extract(data,'$.seq')<? ${includeHidden ? '' : "AND coalesce(json_extract(data,'$.visible'),1) != 0"} ORDER BY json_extract(data,'$.seq') DESC LIMIT ?`,
       )
-      .all(owner, conversationId, before, limit)
+      .all(...(conversationTaskId ? [owner, conversationId, conversationTaskId, before, limit] : [owner, conversationId, before, limit]))
       .map((r) => JSON.parse(String(r.data)) as Message)
       .reverse()
   }
@@ -396,6 +509,13 @@ export class WorkspaceStore {
     this.atomic(() => {
       const c = this.require<Conversation>(owner, 'conversation', message.conversationId)
       const previous = this.get<Message>(owner, 'message', message.id)
+      if (previous && previous.conversationTaskId !== message.conversationTaskId)
+        throw new HttpError(409, '消息不能移入其他任务', 'message_task_mismatch')
+      const task = c.kind === 'group'
+        ? message.conversationTaskId
+          ? this.requireTask(owner, c.id, message.conversationTaskId)
+          : (() => { throw new HttpError(400, '群聊消息必须指定任务', 'workspace_task_required') })()
+        : undefined
       if (previous?.visible === false && message.visible !== false) message.seq = 0
       if (!message.seq) message.seq = c.lastSeq + 1
       c.lastSeq = Math.max(c.lastSeq, message.seq)
@@ -407,6 +527,25 @@ export class WorkspaceStore {
       }
       this.put(owner, 'message', message.id, message)
       this.put(owner, 'conversation', c.id, c)
+      if (task) {
+        const becameVisible = message.visible !== false && (!previous || previous.visible === false),
+          becameHidden = Boolean(previous && previous.visible !== false && message.visible === false)
+        if (becameVisible) task.messageCount += 1
+        if (becameHidden) task.messageCount = Math.max(0, task.messageCount - 1)
+        task.lastSeq = Math.max(task.lastSeq, message.seq)
+        if (becameVisible && message.seq > task.readSeq) task.unreadCount += 1
+        if (becameHidden && message.seq > task.readSeq) task.unreadCount = Math.max(0, task.unreadCount - 1)
+        if (message.visible !== false && (message.content.trim() || message.attachments.length)) {
+          if (message.role === 'user' && task.titleSource === 'automatic' && task.messageCount <= 1) {
+            const source = notificationPlainText(message.content, { maximum: 48, fallback: message.attachments[0]?.name ?? '' }).trim()
+            if (source) task.title = source
+          }
+          task.lastMessageAt = Math.max(task.lastMessageAt ?? 0, message.createdAt)
+          task.updatedAt = Date.now()
+        }
+        this.put(owner, 'conversation-task', task.id, task)
+        this.event(owner, 'task.changed', task, c.id)
+      }
       this.event(owner, 'message.changed', message, c.id)
       this.event(owner, 'conversation.changed', c, c.id)
     })
@@ -415,42 +554,66 @@ export class WorkspaceStore {
     return { ...agent, avatar: agent.avatar || encodeAgentAvatar(defaultAgentIdentity(agent.id, agent.name)) }
   }
   conversationSummary(owner: string, conversation: Conversation): Conversation {
-    const member = conversation.kind === 'direct' ? this.get<Agent>(owner, 'agent', conversation.memberIds[0] ?? '') : undefined
+    const member = conversation.kind === 'direct' ? this.get<Agent>(owner, 'agent', conversation.memberIds[0] ?? '') : undefined,
+      unreadCount = conversation.kind === 'group'
+        ? this.tasks(owner, conversation.id).reduce((count, task) => count + task.unreadCount, 0)
+        : this.messages(owner, conversation.id, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER).filter(message => message.seq > conversation.readSeq).length
     return {
       ...conversation,
-      unreadCount: this.messages(owner, conversation.id, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER).filter(m => m.seq > conversation.readSeq).length,
+      unreadCount,
       ...(member ? { avatar: this.agentSummary(member).avatar } : {}),
       lastMessageAt: conversation.lastMessageAt
         ?? (conversation.lastSeq > 0 ? this.messages(owner, conversation.id, Number.MAX_SAFE_INTEGER, 1).at(-1)?.createdAt : undefined)
         ?? conversation.createdAt,
     }
   }
-  hiddenMessageIds(owner: string, conversationId: string): string[] {
-    return this.messages(owner, conversationId, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, true).filter(m => m.visible === false).map(m => m.id)
+  hiddenMessageIds(owner: string, conversationId: string, conversationTaskId?: string): string[] {
+    return this.messages(owner, conversationId, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, true, conversationTaskId).filter(m => m.visible === false).map(m => m.id)
   }
   saveRun(owner: string, run: Run): void {
     this.atomic(() => {
       const c = this.require<Conversation>(owner, 'conversation', run.conversationId)
       run.updatedAt = Date.now()
       this.put(owner, 'run', run.id, run)
-      const roots = this.list<Run>(owner, 'run').filter(r => r.conversationId === c.id && !['complete', 'failed', 'interrupted'].includes(r.status))
-        .sort((a, b) => this.require<Message>(owner, 'message', a.messageId).seq - this.require<Message>(owner, 'message', b.messageId).seq)
-      const current = roots[0]
-      c.activeRunId = current?.id
-      c.activeAgentId = current?.activeAgentId
-      c.activeRunStatus = current?.status
-      const tasks = this.db.prepare("SELECT data FROM workspace_entities WHERE owner=? AND kind='turn' AND json_extract(data,'$.conversationId')=? AND json_extract(data,'$.status') IN ('running','waiting','uncertain','queued') AND (json_extract(data,'$.status') != 'queued' OR json_extract(data,'$.planned')=1) ORDER BY CASE json_extract(data,'$.status') WHEN 'queued' THEN 0 WHEN 'running' THEN 1 ELSE 2 END").all(owner, c.id)
-        .map(row => JSON.parse(String(row.data)) as { agentId: string; status: string })
-      c.activeAgentStates = Object.fromEntries(tasks.map(t => [t.agentId, t.status as 'running' | 'waiting' | 'uncertain' | 'queued']))
-      const outcomes = this.db.prepare("SELECT data FROM workspace_entities WHERE owner=? AND kind='turn' AND json_extract(data,'$.conversationId')=? AND json_extract(data,'$.status') IN ('complete','failed') AND json_extract(data,'$.updatedAt')>=? ORDER BY json_extract(data,'$.updatedAt') ASC").all(owner, c.id, Date.now() - 2000)
-        .map(row => JSON.parse(String(row.data)) as {id:string;agentId:string;status:string;updatedAt:number})
-      c.avatarSignals = Object.fromEntries(outcomes.filter(t => Date.now() - t.updatedAt < 2000).map(t => [t.agentId, { id:t.id, state:t.status === 'failed' ? 'failure' as const : 'success' as const, at:t.updatedAt }]))
-      c.queuedMessageCount = roots.filter(r => r.status === 'queued').length
+      if (run.conversationTaskId) {
+        const task = this.requireTask(owner, c.id, run.conversationTaskId),
+          activeRuns = this.list<Run>(owner, 'run')
+            .filter(root => root.conversationTaskId === task.id && !['complete', 'failed', 'interrupted'].includes(root.status))
+            .sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)),
+          active = activeRuns[0]
+        task.activeRunId = active?.id
+        task.activeRunStatus = active?.status
+        this.put(owner, 'conversation-task', task.id, task)
+        this.event(owner, 'task.changed', task, c.id)
+      }
+      this.projectConversationRuns(owner, c)
       this.put(owner, 'conversation', c.id, c)
       this.event(owner, 'run.changed', run, c.id)
       this.event(owner, 'conversation.changed', c, c.id)
     })
   }
+  activeTaskCount(owner: string, conversationId: string): number {
+    return new Set(
+      this.list<Run>(owner, 'run')
+        .filter(run => run.conversationId === conversationId && run.conversationTaskId && !['complete', 'failed', 'interrupted'].includes(run.status))
+        .map(run => run.conversationTaskId!),
+    ).size
+  }
+  private projectConversationRuns(owner: string, conversation: Conversation): void {
+    const roots = this.list<Run>(owner, 'run').filter(run => run.conversationId === conversation.id && !['complete', 'failed', 'interrupted'].includes(run.status))
+      .sort((a, b) => this.require<Message>(owner, 'message', a.messageId).seq - this.require<Message>(owner, 'message', b.messageId).seq),
+      current = roots[0]
+    conversation.activeRunId = current?.id
+    conversation.activeAgentId = current?.activeAgentId
+    conversation.activeRunStatus = current?.status
+    const turns = this.db.prepare("SELECT data FROM workspace_entities WHERE owner=? AND kind='turn' AND json_extract(data,'$.conversationId')=? AND json_extract(data,'$.status') IN ('running','waiting','uncertain','queued') AND (json_extract(data,'$.status') != 'queued' OR json_extract(data,'$.planned')=1) ORDER BY CASE json_extract(data,'$.status') WHEN 'queued' THEN 0 WHEN 'running' THEN 1 ELSE 2 END").all(owner, conversation.id)
+      .map(row => JSON.parse(String(row.data)) as { agentId: string; status: string })
+    conversation.activeAgentStates = Object.fromEntries(turns.map(turn => [turn.agentId, turn.status as 'running' | 'waiting' | 'uncertain' | 'queued']))
+    const outcomes = this.db.prepare("SELECT data FROM workspace_entities WHERE owner=? AND kind='turn' AND json_extract(data,'$.conversationId')=? AND json_extract(data,'$.status') IN ('complete','failed') AND json_extract(data,'$.updatedAt')>=? ORDER BY json_extract(data,'$.updatedAt') ASC").all(owner, conversation.id, Date.now() - 2000)
+      .map(row => JSON.parse(String(row.data)) as {id:string;agentId:string;status:string;updatedAt:number})
+    conversation.avatarSignals = Object.fromEntries(outcomes.filter(turn => Date.now() - turn.updatedAt < 2000).map(turn => [turn.agentId, { id:turn.id, state:turn.status === 'failed' ? 'failure' as const : 'success' as const, at:turn.updatedAt }]))
+    conversation.queuedMessageCount = roots.filter(run => run.status === 'queued').length
+  }
   ownsUpstream(id: string): boolean {
     return Boolean(
       this.db

+ 19 - 0
src/shared/workspace.ts

@@ -44,6 +44,21 @@ export interface WorkspaceConversation {
   createdAt: number
   updatedAt: number
 }
+export interface WorkspaceTask {
+  id: string
+  conversationId: string
+  title: string
+  titleSource: 'automatic' | 'user'
+  messageCount: number
+  readSeq: number
+  lastSeq: number
+  unreadCount: number
+  activeRunId?: string
+  activeRunStatus?: WorkspaceRun['status']
+  lastMessageAt?: number
+  createdAt: number
+  updatedAt: number
+}
 export interface WorkspaceFile {
   id: string
   name: string
@@ -59,6 +74,8 @@ export interface WorkspaceFile {
 export interface WorkspaceMessage {
   id: string
   conversationId: string
+  /** User-visible task scope. `taskId` below remains the scheduler turn ID. */
+  conversationTaskId?: string
   seq: number
   role: 'user' | 'assistant' | 'system'
   agentId?: string
@@ -77,6 +94,7 @@ export interface WorkspaceMessage {
 export interface WorkspaceRun {
   id: string
   conversationId: string
+  conversationTaskId?: string
   messageId: string
   mentionIds: string[]
   activeAgentId?: string
@@ -90,6 +108,7 @@ export interface WorkspaceRun {
 export interface WorkspaceInteraction {
   id: string
   conversationId: string
+  conversationTaskId?: string
   runId: string
   agentId: string
   kind: 'approval' | 'clarification'

+ 105 - 0
tests/server/workspace.test.ts

@@ -109,6 +109,9 @@ function direct(id: string, user = owner) {
     .list<WorkspaceConversation>(user, 'conversation')
     .find((c) => c.kind === 'direct' && c.memberIds[0] === id)!
 }
+function defaultTask(conversationId: string, user = owner) {
+  return store.tasks(user, conversationId)[0]!
+}
 async function finished(id: string) {
   await vi.waitFor(() =>
     expect(store.require<WorkspaceRun>(owner, 'run', id).status).toBe('complete'),
@@ -116,6 +119,108 @@ async function finished(id: string) {
   return store.require<WorkspaceRun>(owner, 'run', id)
 }
 describe('Web-owned workspace', () => {
+  it('isolates messages, Hermes sessions, bindings, and context between concurrent group tasks', async () => {
+    const administrator = store.createAgent(owner, { name: '多任务远程管理员', profile: 'remote-profile', nodeId: 'remote-node' }),
+      member = agent('多任务成员'),
+      conversation = store.createGroup(owner, {
+        name: '多任务群聊',
+        memberIds: [administrator.id, member.id],
+        administratorId: administrator.id,
+      }),
+      firstTask = defaultTask(conversation.id),
+      secondTask = store.createTask(owner, conversation.id, { title: '显式标题' })
+    const pending: Array<{ socket: WebSocket; params: Record<string, any> }> = []
+    reply = (socket, params) => {
+      pending.push({ socket, params })
+      if (pending.length !== 2) return
+      for (const item of pending) {
+        const text = item.params.text.includes('第一项工作') ? '第一项结果' : '第二项结果'
+        setTimeout(() => item.socket.send(JSON.stringify({
+          method: 'event',
+          params: { type: 'message.complete', session_id: item.params.session_id, payload: { text, status: 'complete' } },
+        })), 5)
+      }
+    }
+    const firstRun = runtime.send(owner, conversation.id, { requestId: randomUUID(), taskId: firstTask.id, content: '第一项工作' }),
+      secondRun = runtime.send(owner, conversation.id, { requestId: randomUUID(), taskId: secondTask.id, content: '第二项工作' })
+    await Promise.all([finished(firstRun.id), finished(secondRun.id)])
+    const firstMessages = store.messages(owner, conversation.id, Number.MAX_SAFE_INTEGER, 100, false, firstTask.id),
+      secondMessages = store.messages(owner, conversation.id, Number.MAX_SAFE_INTEGER, 100, false, secondTask.id)
+    expect(firstMessages.map(message => message.content)).toEqual(['第一项工作', '第一项结果'])
+    expect(secondMessages.map(message => message.content)).toEqual(['第二项工作', '第二项结果'])
+    expect(firstMessages.every(message => message.conversationTaskId === firstTask.id)).toBe(true)
+    expect(secondMessages.every(message => message.conversationTaskId === secondTask.id)).toBe(true)
+    const prompts = requests.filter(request => request.method === 'prompt.submit').map(request => String(request.params.text))
+    expect(prompts.find(prompt => prompt.includes('第一项工作'))).not.toContain('第二项工作')
+    expect(prompts.find(prompt => prompt.includes('第二项工作'))).not.toContain('第一项工作')
+    const bindings = store.list<any>(owner, 'binding')
+    expect(bindings.map(binding => binding.conversationTaskId).sort()).toEqual([firstTask.id, secondTask.id].sort())
+    expect(bindings.every(binding => binding.nodeId === 'remote-node' && binding.profile === 'remote-profile')).toBe(true)
+    expect(new Set(bindings.map(binding => binding.storedId)).size).toBe(2)
+    expect(store.get<any>(owner, 'context', firstTask.id)).toMatchObject({ conversationTaskId: firstTask.id, percent: 40 })
+    expect(store.get<any>(owner, 'context', secondTask.id)).toMatchObject({ conversationTaskId: secondTask.id, percent: 40 })
+    const firstSummary = store.require<any>(owner, 'conversation-task', firstTask.id)
+    expect(firstSummary).toMatchObject({ title: '第一项工作', titleSource: 'automatic', messageCount: 2, unreadCount: 2 })
+    expect(store.require<any>(owner, 'conversation-task', secondTask.id)).toMatchObject({ title: '显式标题', titleSource: 'user', messageCount: 2 })
+    expect(store.markTaskRead(owner, conversation.id, firstTask.id, firstSummary.lastSeq).unreadCount).toBe(0)
+    store.deleteTask(owner, conversation.id, firstTask.id)
+    expect(store.list<WorkspaceMessage>(owner, 'message').some(message => message.conversationTaskId === firstTask.id)).toBe(false)
+    expect(store.list<any>(owner, 'binding').some(binding => binding.conversationTaskId === firstTask.id)).toBe(false)
+    expect(store.get(owner, 'context', firstTask.id)).toBeUndefined()
+    expect(store.messages(owner, conversation.id, Number.MAX_SAFE_INTEGER, 100, false, secondTask.id).map(message => message.content))
+      .toEqual(['第二项工作', '第二项结果'])
+  })
+
+  it('limits a group to four active conversation tasks while retaining the global scheduler limit', async () => {
+    const administrator = agent('并发任务管理员'),
+      member = agent('并发任务成员'),
+      conversation = store.createGroup(owner, {
+        name: '并发任务群聊',
+        memberIds: [administrator.id, member.id],
+        administratorId: administrator.id,
+      }),
+      tasks = [defaultTask(conversation.id), ...Array.from({ length: 4 }, (_, index) => store.createTask(owner, conversation.id, { title: `任务 ${index + 2}` }))]
+    reply = () => {}
+    for (const [index, task] of tasks.slice(0, 4).entries())
+      runtime.send(owner, conversation.id, { requestId: randomUUID(), taskId: task.id, content: `执行 ${index + 1}` })
+    expect(store.activeTaskCount(owner, conversation.id)).toBe(4)
+    expect(() => runtime.send(owner, conversation.id, { requestId: randomUUID(), taskId: tasks[4]!.id, content: '第五项' }))
+      .toThrow('最多同时运行 4 个任务')
+    await Promise.all(tasks.slice(0, 4).map(task => runtime.stopTask(owner, conversation.id, task.id)))
+  })
+
+  it('keeps same-named upstream interactions scoped to their conversation task', async () => {
+    const administrator = agent('交互隔离管理员'),
+      member = agent('交互隔离成员'),
+      conversation = store.createGroup(owner, {
+        name: '交互隔离群聊',
+        memberIds: [administrator.id, member.id],
+        administratorId: administrator.id,
+      }),
+      firstTask = defaultTask(conversation.id),
+      secondTask = store.createTask(owner, conversation.id, {})
+    reply = (socket, params) => setTimeout(() => socket.send(JSON.stringify({
+      method: 'event',
+      params: {
+        type: 'approval.request',
+        session_id: params.session_id,
+        payload: { request_id: 'same-upstream-id', message: '允许执行吗?' },
+      },
+    })), 5)
+    runtime.send(owner, conversation.id, { requestId: randomUUID(), taskId: firstTask.id, content: '任务甲' })
+    runtime.send(owner, conversation.id, { requestId: randomUUID(), taskId: secondTask.id, content: '任务乙' })
+    await vi.waitFor(() => expect(store.list<WorkspaceInteraction>(owner, 'interaction').filter(interaction => !interaction.resolved)).toHaveLength(2))
+    const interactions = store.list<WorkspaceInteraction>(owner, 'interaction'),
+      first = interactions.find(interaction => interaction.conversationTaskId === firstTask.id)!,
+      second = interactions.find(interaction => interaction.conversationTaskId === secondTask.id)!
+    expect(store.require<any>(owner, 'conversation-task', firstTask.id).activeRunStatus).toBe('waiting')
+    expect(store.require<any>(owner, 'conversation-task', secondTask.id).activeRunStatus).toBe('waiting')
+    await runtime.respond(owner, first.id, 'once')
+    expect(store.require<WorkspaceInteraction>(owner, 'interaction', first.id).resolved).toBe(true)
+    expect(store.require<WorkspaceInteraction>(owner, 'interaction', second.id).resolved).toBe(false)
+    await Promise.all([runtime.stopTask(owner, conversation.id, firstTask.id), runtime.stopTask(owner, conversation.id, secondTask.id)])
+  })
+
   it('persists a visible failure when a fixed member loses its source node',async()=>{
     const a=agent('不可用成员'),c=direct(a.id)
     runtime.close()

+ 50 - 0
tests/server/workspaceRoutes.test.ts

@@ -143,6 +143,56 @@ describe('application workspace HTTP contract', () => {
     expect((await req('get', '/api/app/events', 'second')).body.events).toEqual([])
     expect(upstream.some((p) => p.includes('sessions'))).toBe(false)
   })
+  it('creates, lists, renames, selects, and deletes group tasks without changing remote members', async () => {
+    const a = (
+      await req('post', '/api/app/agents').send({ name: '本机管理员', profile: 'default' }).expect(201)
+    ).body.agent
+    const b = (
+      await req('post', '/api/app/agents').send({ name: '远程成员', nodeId: 'local', profile: 'default' }).expect(201)
+    ).body.agent
+    const conversation = (
+      await req('post', '/api/app/conversations')
+        .send({ name: '多任务团队', memberIds: [a.id, b.id], administratorId: a.id })
+        .expect(201)
+    ).body.conversation
+    const initial = (await req('get', `/api/app/conversations/${conversation.id}/tasks`).expect(200)).body.tasks
+    expect(initial).toHaveLength(1)
+    expect(initial[0]).toMatchObject({
+      conversationId: conversation.id,
+      title: '新任务',
+      titleSource: 'automatic',
+      messageCount: 0,
+    })
+    const task = (
+      await req('post', `/api/app/conversations/${conversation.id}/tasks`)
+        .send({ title: '远程 Agent 调研' })
+        .expect(201)
+    ).body.task
+    expect(task).toMatchObject({ title: '远程 Agent 调研', titleSource: 'user' })
+    const renamed = (
+      await req('patch', `/api/app/conversations/${conversation.id}/tasks/${task.id}`)
+        .send({ title: '远程 Agent 复核' })
+        .expect(200)
+    ).body.task
+    expect(renamed).toMatchObject({ title: '远程 Agent 复核', titleSource: 'user' })
+    const detail = (await req('get', `/api/app/conversations/${conversation.id}?taskId=${task.id}`).expect(200)).body
+    expect(detail.task.id).toBe(task.id)
+    expect(detail.tasks.map((row: { id: string }) => row.id)).toContain(task.id)
+    expect(detail.messages).toEqual([])
+    expect(detail.conversation).toMatchObject({ memberIds: [a.id, b.id], administratorId: a.id })
+    expect((await req('put', `/api/app/conversations/${conversation.id}/tasks/${task.id}/read`).send({ seq: 100 }).expect(200)).body.task)
+      .toMatchObject({ readSeq: 0, lastSeq: 0, unreadCount: 0 })
+    await req('get', `/api/app/conversations/${conversation.id}/tasks`, 'second').expect(404)
+    await req('delete', `/api/app/conversations/${conversation.id}/tasks/${task.id}`).expect(200)
+    const remaining = (await req('get', `/api/app/conversations/${conversation.id}/tasks`).expect(200)).body.tasks
+    expect(remaining.map((row: { id: string }) => row.id)).not.toContain(task.id)
+    expect((await req('get', `/api/app/conversations/${conversation.id}`)).body.conversation)
+      .toMatchObject({ memberIds: [a.id, b.id], administratorId: a.id })
+    await req('delete', `/api/app/conversations/${conversation.id}/tasks/${initial[0].id}`).expect(200)
+    const replacement = (await req('get', `/api/app/conversations/${conversation.id}/tasks`).expect(200)).body.tasks
+    expect(replacement).toHaveLength(1)
+    expect(replacement[0].id).not.toBe(initial[0].id)
+  })
   it('archives files on the Web server, preserves native library fields, and blocks cross-user downloads', async () => {
     const f = (
       await req('post', '/api/app/uploads')