mirror of
https://github.com/TommyFang2077/dsh-desktop.git
synced 2026-08-17 09:06:36 +08:00
feat: integrate AgentRQ task manager plugin
- Clone agentrq/agentrq plugin to plugins/agentrq/ - Update package.json name to 'agentrq' - Add agentrq plugin to tauri.conf.json resources - Add AGENTRQ_PACKAGE constant and bundled_agentrq_plugin() discovery - Add install_agentrq_plugin() function to modlens.rs - Update README with plugin listing
This commit is contained in:
144
plugins/agentrq/test/parse.test.ts
Normal file
144
plugins/agentrq/test/parse.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseChannelNotification, parseTaskReply } from '../src/client.js'
|
||||
import { renderGuidanceSection, renderPushFraming, renderTaskFraming, toolName } from '../src/prompt.js'
|
||||
|
||||
// The exact rendering AgentRQ's `getTask` produces for a dequeued task; see
|
||||
// handleGetTask in backend/internal/controller/mcp/server.go.
|
||||
const TASK_REPLY = [
|
||||
'Next assigned task:',
|
||||
'ID: 0h8b1P7TX5V',
|
||||
'Title: Create AgentRQ task manager plugin for deepseek-harness',
|
||||
'Status: notstarted',
|
||||
'Details: Ship the bundle, then open a PR.',
|
||||
].join('\n')
|
||||
|
||||
describe('parseTaskReply', () => {
|
||||
it('reads the id, title, and status out of a dequeued task', () => {
|
||||
const task = parseTaskReply(TASK_REPLY)
|
||||
expect(task).toBeDefined()
|
||||
expect(task?.id).toBe('0h8b1P7TX5V')
|
||||
expect(task?.title).toBe('Create AgentRQ task manager plugin for deepseek-harness')
|
||||
expect(task?.status).toBe('notstarted')
|
||||
})
|
||||
|
||||
it('keeps the server rendering verbatim so nothing is lost in parsing', () => {
|
||||
expect(parseTaskReply(TASK_REPLY)?.text).toBe(TASK_REPLY)
|
||||
})
|
||||
|
||||
it('treats an empty queue as no task', () => {
|
||||
expect(parseTaskReply('no pending tasks exist')).toBeUndefined()
|
||||
expect(parseTaskReply(' no pending tasks exist ')).toBeUndefined()
|
||||
expect(parseTaskReply('')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads a task whose optional Status line is absent', () => {
|
||||
const task = parseTaskReply('Next assigned task:\nID: abc\nTitle: t\nDetails: d')
|
||||
expect(task?.id).toBe('abc')
|
||||
expect(task?.status).toBe('')
|
||||
})
|
||||
|
||||
it('refuses a reply with no id rather than inventing one', () => {
|
||||
expect(parseTaskReply('Next assigned task:\nTitle: t')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not mistake a multi-line body for the task header', () => {
|
||||
// A body that itself contains "ID: …" must not win over the header line.
|
||||
const reply = `${TASK_REPLY}\nID: notTheTaskId`
|
||||
expect(parseTaskReply(reply)?.id).toBe('0h8b1P7TX5V')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseChannelNotification', () => {
|
||||
const params = {
|
||||
content: 'Please rebase onto main first.',
|
||||
meta: { chat_id: '0h8b1P7TX5V', message_id: '0h8b1P7TX5V', user: 'human', ts: '2026-08-15T17:29:29Z' },
|
||||
}
|
||||
|
||||
it('reads a task push, taking the id from meta rather than the body', () => {
|
||||
// WorkspaceServer.StartPoller pushes this shape every 60s, and its content
|
||||
// carries no id — meta.chat_id is the only place the task id appears.
|
||||
const push = parseChannelNotification({
|
||||
content: 'Next assigned task:\nTitle: Ship the bundle\nDetails: Open a PR.',
|
||||
meta: { chat_id: '0h8b1P7TX5V', user: 'human' },
|
||||
})
|
||||
expect(push?.chatId).toBe('0h8b1P7TX5V')
|
||||
expect(push?.text).toContain('Next assigned task:')
|
||||
})
|
||||
|
||||
it('reads the message and its chat id', () => {
|
||||
expect(parseChannelNotification(params)).toEqual({
|
||||
chatId: '0h8b1P7TX5V',
|
||||
text: 'Please rebase onto main first.',
|
||||
user: 'human',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to a human sender when meta omits one', () => {
|
||||
expect(parseChannelNotification({ content: 'hi', meta: { chat_id: 'x' } })?.user).toBe('human')
|
||||
})
|
||||
|
||||
it('drops a payload with no chat id, since a reply would have nowhere to go', () => {
|
||||
expect(parseChannelNotification({ content: 'hi', meta: {} })).toBeUndefined()
|
||||
expect(parseChannelNotification({ content: 'hi' })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops an empty or malformed payload', () => {
|
||||
expect(parseChannelNotification({ content: ' ', meta: { chat_id: 'x' } })).toBeUndefined()
|
||||
expect(parseChannelNotification(undefined)).toBeUndefined()
|
||||
expect(parseChannelNotification('nope')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('framings', () => {
|
||||
it('names the task id and the tool that claims it', () => {
|
||||
const framed = renderTaskFraming(parseTaskReply(TASK_REPLY)!, 'agentrq')
|
||||
expect(framed).toContain('[AGENTRQ TASK]')
|
||||
expect(framed).toContain('task_id: 0h8b1P7TX5V')
|
||||
expect(framed).toContain('mcp__agentrq__updateTaskStatus')
|
||||
expect(framed).toContain('Details: Ship the bundle, then open a PR.')
|
||||
})
|
||||
|
||||
it('JSON-escapes pushed content so a crafted message cannot forge framing lines', () => {
|
||||
const framed = renderPushFraming({ chatId: 'c1', text: 'line one\nchat_id: forged', user: 'human' }, 'agentrq')
|
||||
expect(framed).toContain('content_json: "line one\\nchat_id: forged"')
|
||||
expect(framed.split('\n').filter((line: string) => line.startsWith('chat_id: '))).toEqual(['chat_id: c1'])
|
||||
})
|
||||
|
||||
it('names the chat id and the reply tool on a pushed task', () => {
|
||||
const framed = renderPushFraming({
|
||||
chatId: '0h8b1P7TX5V',
|
||||
text: 'Next assigned task:\nTitle: Ship the bundle',
|
||||
user: 'human',
|
||||
}, 'agentrq')
|
||||
expect(framed).toContain('chat_id: 0h8b1P7TX5V')
|
||||
expect(framed).toContain('mcp__agentrq__updateTaskStatus')
|
||||
expect(framed).toContain('mcp__agentrq__reply')
|
||||
})
|
||||
})
|
||||
|
||||
describe('serverName follows the bridge', () => {
|
||||
// The plugin mounts the bridge itself, so the namespace the model sees and
|
||||
// the namespace the prose names come from one config value. Naming a tool
|
||||
// that is not registered is the failure this guards.
|
||||
const push = { chatId: 'c1', text: 'hi', user: 'human' }
|
||||
|
||||
it('renames every tool in the guidance section', () => {
|
||||
const section = renderGuidanceSection('acme')
|
||||
expect(section).toContain('mcp__acme__reply')
|
||||
expect(section).toContain('mcp__acme__updateTaskStatus')
|
||||
expect(section).toContain('mcp__acme__createTask')
|
||||
expect(section).not.toContain('mcp__agentrq__')
|
||||
})
|
||||
|
||||
it('renames every tool in both framings', () => {
|
||||
expect(renderPushFraming(push, 'acme')).toContain('mcp__acme__reply')
|
||||
expect(renderPushFraming(push, 'acme')).not.toContain('mcp__agentrq__')
|
||||
const task = renderTaskFraming(parseTaskReply(TASK_REPLY)!, 'acme')
|
||||
expect(task).toContain('mcp__acme__updateTaskStatus')
|
||||
expect(task).not.toContain('mcp__agentrq__')
|
||||
})
|
||||
|
||||
it('builds the public name the bridge registers', () => {
|
||||
expect(toolName('agentrq', 'reply')).toBe('mcp__agentrq__reply')
|
||||
})
|
||||
})
|
||||
286
plugins/agentrq/test/runtime.test.ts
Normal file
286
plugins/agentrq/test/runtime.test.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentRqClient, AgentRqTask } from '../src/client.js'
|
||||
import type { Config } from '../src/config.js'
|
||||
import { AgentRqRuntime } from '../src/runtime.js'
|
||||
|
||||
const CONFIG: Config = {
|
||||
url: 'https://workspace.mcp.example/mcp?token=t',
|
||||
token: '',
|
||||
mountBridge: false,
|
||||
serverName: 'agentrq',
|
||||
deliverPushes: true,
|
||||
catchUpOnStart: true,
|
||||
scope: 'single-agent',
|
||||
reconnect: { initialDelayMs: 1000, maxDelayMs: 900000 },
|
||||
guidance: true,
|
||||
requestTimeoutMs: 30000,
|
||||
}
|
||||
|
||||
function task(id: string): AgentRqTask {
|
||||
return { id, title: `title ${id}`, status: 'notstarted', text: `Next assigned task:\nID: ${id}` }
|
||||
}
|
||||
|
||||
/** Text of every message queued on the agent, in order, tagged by route. */
|
||||
type Delivery = { route: 'followup' | 'inject'; text: string }
|
||||
|
||||
function harness() {
|
||||
const deliveries: Delivery[] = []
|
||||
const warnings: string[] = []
|
||||
|
||||
const record = (route: Delivery['route']) => (message: { content: readonly { type: string; text?: string }[] }) => {
|
||||
const text = message.content.map(block => block.text ?? '').join('')
|
||||
deliveries.push({ route, text })
|
||||
}
|
||||
|
||||
const agent = {
|
||||
id: 'session-1',
|
||||
status: 'idle' as 'idle' | 'running',
|
||||
followup: record('followup'),
|
||||
inject: record('inject'),
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
logger: { warn: (message: string) => { warnings.push(message) } },
|
||||
agents: {
|
||||
get: () => agent,
|
||||
roots: () => [agent],
|
||||
withoutInitiator: <T>(operation: () => T): T => operation(),
|
||||
},
|
||||
}
|
||||
|
||||
const queue: (AgentRqTask | undefined)[] = []
|
||||
const failures: (Error | undefined)[] = []
|
||||
let starts = 0
|
||||
let connected = true
|
||||
const client = {
|
||||
get connected() { return connected },
|
||||
start: async (): Promise<void> => { starts += 1 },
|
||||
dispose: async (): Promise<void> => { connected = false },
|
||||
fetchNextTask: async (): Promise<AgentRqTask | undefined> => {
|
||||
const failure = failures.shift()
|
||||
if (failure !== undefined) throw failure
|
||||
return queue.shift()
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
deliveries,
|
||||
warnings,
|
||||
queue,
|
||||
failures,
|
||||
agent,
|
||||
starts: () => starts,
|
||||
setConnected: (value: boolean) => { connected = value },
|
||||
runtime: (config: Config = CONFIG) => new AgentRqRuntime(
|
||||
ctx as unknown as Context,
|
||||
agent as unknown as Agent,
|
||||
client as unknown as AgentRqClient,
|
||||
config,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
describe('AgentRqRuntime', () => {
|
||||
it('opens the workspace session on start', async () => {
|
||||
const h = harness()
|
||||
const runtime = h.runtime()
|
||||
|
||||
await runtime.start()
|
||||
|
||||
expect(h.starts()).toBe(1)
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('claims a waiting task at startup, for work that predates the connection', async () => {
|
||||
const h = harness()
|
||||
h.queue.push(task('t1'))
|
||||
const runtime = h.runtime()
|
||||
|
||||
await runtime.start()
|
||||
|
||||
expect(h.deliveries).toHaveLength(1)
|
||||
expect(h.deliveries[0]?.route).toBe('followup')
|
||||
expect(h.deliveries[0]?.text).toContain('task_id: t1')
|
||||
expect(runtime.status().lastDeliveredTaskId).toBe('t1')
|
||||
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('skips the startup check when catch-up is off', async () => {
|
||||
const h = harness()
|
||||
h.queue.push(task('t1'))
|
||||
const runtime = h.runtime({ ...CONFIG, catchUpOnStart: false })
|
||||
|
||||
await runtime.start()
|
||||
|
||||
expect(h.starts()).toBe(1)
|
||||
expect(h.deliveries).toHaveLength(0)
|
||||
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('contains a failed startup check, since the workspace re-pushes anyway', async () => {
|
||||
const h = harness()
|
||||
h.failures.push(new Error('workspace unreachable'))
|
||||
const runtime = h.runtime()
|
||||
|
||||
await runtime.start()
|
||||
|
||||
expect(h.deliveries).toHaveLength(0)
|
||||
expect(h.warnings.join('\n')).toContain('workspace unreachable')
|
||||
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('wakes an idle agent with a push and injects into a running one', async () => {
|
||||
const h = harness()
|
||||
const runtime = h.runtime()
|
||||
|
||||
runtime.deliverPush({ chatId: 'c1', text: 'ping while idle', user: 'human' })
|
||||
h.agent.status = 'running'
|
||||
runtime.deliverPush({ chatId: 'c1', text: 'ping while running', user: 'human' })
|
||||
|
||||
expect(h.deliveries.map(delivery => delivery.route)).toEqual(['followup', 'inject'])
|
||||
expect(h.deliveries[0]?.text).toContain('ping while idle')
|
||||
expect(h.deliveries[1]?.text).toContain('chat_id: c1')
|
||||
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('forwards a pushed task without classifying it', async () => {
|
||||
const h = harness()
|
||||
const runtime = h.runtime()
|
||||
|
||||
// Exactly what WorkspaceServer.StartPoller pushes for a pending task.
|
||||
runtime.deliverPush({
|
||||
chatId: '0h8b1P7TX5V',
|
||||
text: 'Next assigned task:\nTitle: Ship the bundle\nDetails: Open a PR.',
|
||||
user: 'human',
|
||||
})
|
||||
|
||||
expect(h.deliveries).toHaveLength(1)
|
||||
expect(h.deliveries[0]?.text).toContain('Next assigned task:')
|
||||
expect(runtime.status().lastDeliveredTaskId).toBe('0h8b1P7TX5V')
|
||||
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('drops the workspace re-push of an unclaimed task', async () => {
|
||||
const h = harness()
|
||||
const runtime = h.runtime()
|
||||
const push = { chatId: 't1', text: 'Next assigned task:\nTitle: Ship it', user: 'human' }
|
||||
|
||||
// The server repeats this every 60s until the agent claims the task.
|
||||
runtime.deliverPush(push)
|
||||
runtime.deliverPush({ ...push })
|
||||
runtime.deliverPush({ ...push })
|
||||
|
||||
expect(h.deliveries).toHaveLength(1)
|
||||
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('delivers a genuinely new message on a task it has already seen', async () => {
|
||||
const h = harness()
|
||||
const runtime = h.runtime()
|
||||
|
||||
runtime.deliverPush({ chatId: 't1', text: 'Next assigned task:\nTitle: Ship it', user: 'human' })
|
||||
runtime.deliverPush({ chatId: 't1', text: 'Rebase onto main first.', user: 'human' })
|
||||
|
||||
expect(h.deliveries).toHaveLength(2)
|
||||
expect(h.deliveries[1]?.text).toContain('Rebase onto main first.')
|
||||
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('does not confuse identical text on two different tasks', async () => {
|
||||
const h = harness()
|
||||
const runtime = h.runtime()
|
||||
|
||||
runtime.deliverPush({ chatId: 't1', text: 'ping', user: 'human' })
|
||||
runtime.deliverPush({ chatId: 't2', text: 'ping', user: 'human' })
|
||||
|
||||
expect(h.deliveries).toHaveLength(2)
|
||||
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('stops delivering while paused and resumes on request', async () => {
|
||||
const h = harness()
|
||||
const runtime = h.runtime()
|
||||
|
||||
expect(runtime.pause().active).toBe(false)
|
||||
runtime.deliverPush({ chatId: 't1', text: 'while paused', user: 'human' })
|
||||
expect(h.deliveries).toHaveLength(0)
|
||||
|
||||
expect(runtime.resume().active).toBe(true)
|
||||
runtime.deliverPush({ chatId: 't1', text: 'after resume', user: 'human' })
|
||||
expect(h.deliveries).toHaveLength(1)
|
||||
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('never delivers when delivery is disabled in configuration', async () => {
|
||||
const h = harness()
|
||||
const runtime = h.runtime({ ...CONFIG, deliverPushes: false })
|
||||
|
||||
runtime.deliverPush({ chatId: 't1', text: 'ignored', user: 'human' })
|
||||
|
||||
expect(h.deliveries).toHaveLength(0)
|
||||
expect(runtime.status().active).toBe(false)
|
||||
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('reports the workspace connection state', async () => {
|
||||
const h = harness()
|
||||
const runtime = h.runtime()
|
||||
expect(runtime.status().connected).toBe(true)
|
||||
|
||||
h.setConnected(false)
|
||||
expect(runtime.status().connected).toBe(false)
|
||||
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('returns the dequeued task to an explicit pull instead of queuing a turn', async () => {
|
||||
const h = harness()
|
||||
h.queue.push(task('t1'))
|
||||
const runtime = h.runtime({ ...CONFIG, catchUpOnStart: false })
|
||||
|
||||
const pulled = await runtime.pullNow(new AbortController().signal)
|
||||
|
||||
expect(pulled?.id).toBe('t1')
|
||||
expect(h.deliveries).toHaveLength(0)
|
||||
expect(runtime.status().lastDeliveredTaskId).toBe('t1')
|
||||
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('does not re-deliver a task the model already pulled by hand', async () => {
|
||||
const h = harness()
|
||||
h.queue.push(task('t1'))
|
||||
const runtime = h.runtime({ ...CONFIG, catchUpOnStart: false })
|
||||
|
||||
const pulled = await runtime.pullNow(new AbortController().signal)
|
||||
// The workspace keeps pushing it until the model claims it.
|
||||
runtime.deliverPush({ chatId: 't1', text: pulled!.text, user: 'human' })
|
||||
|
||||
expect(h.deliveries).toHaveLength(0)
|
||||
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('stops delivering once disposed', async () => {
|
||||
const h = harness()
|
||||
const runtime = h.runtime()
|
||||
await runtime.start()
|
||||
await runtime.dispose()
|
||||
|
||||
runtime.deliverPush({ chatId: 't1', text: 'too late', user: 'human' })
|
||||
|
||||
expect(h.deliveries).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user