@raidou/pi-pm-subagents 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prettierrc +7 -0
- package/AGENTS.md +1 -0
- package/README.md +85 -0
- package/README.zh-CN.md +85 -0
- package/agents/explorer.md +10 -0
- package/agents/planner.md +16 -0
- package/agents/researcher.md +22 -0
- package/agents/reviewer.md +10 -0
- package/eslint.config.mjs +14 -0
- package/example-prompts/coordinator.md +21 -0
- package/package.json +48 -0
- package/pm-subagents-prompts/coordinator.md +16 -0
- package/pnpm-workspace.yaml +5 -0
- package/src/bash-readonly.test.ts +331 -0
- package/src/bash-readonly.ts +205 -0
- package/src/coordinator/coordinator.test.ts +28 -0
- package/src/coordinator/coordinator.ts +275 -0
- package/src/custom-select.test.ts +91 -0
- package/src/custom-select.ts +209 -0
- package/src/index.ts +87 -0
- package/src/models-config/models-config.test.ts +88 -0
- package/src/models-config/models-config.ts +205 -0
- package/src/models-config/scoped-models-editor.test.ts +189 -0
- package/src/models-config/scoped-models-editor.ts +412 -0
- package/src/models-config/subagent-model-constants.ts +2 -0
- package/src/models-config/subagent-model-cycle.ts +53 -0
- package/src/models-config/subagent-model-utils.test.ts +250 -0
- package/src/models-config/subagent-model-utils.ts +52 -0
- package/src/pm-mode.test.ts +324 -0
- package/src/pm-mode.ts +142 -0
- package/src/prompts/mode.test.ts +289 -0
- package/src/prompts/mode.ts +31 -0
- package/src/prompts/roles.test.ts +724 -0
- package/src/prompts/roles.ts +119 -0
- package/src/subagent/activity.test.ts +230 -0
- package/src/subagent/activity.ts +60 -0
- package/src/subagent/batcher.test.ts +198 -0
- package/src/subagent/batcher.ts +51 -0
- package/src/subagent/consts.ts +1 -0
- package/src/subagent/demo.ts +773 -0
- package/src/subagent/fleet.test.ts +1758 -0
- package/src/subagent/fleet.ts +376 -0
- package/src/subagent/identity.test.ts +31 -0
- package/src/subagent/identity.ts +16 -0
- package/src/subagent/manager.test.ts +392 -0
- package/src/subagent/manager.ts +277 -0
- package/src/subagent/tools.ts +314 -0
- package/src/subagent/viewer.ts +305 -0
- package/src/types.ts +15 -0
- package/src/ui/border-view.ts +50 -0
- package/src/ui/review-pager.ts +146 -0
- package/src/ui/scroll-view.test.ts +190 -0
- package/src/ui/scroll-view.ts +155 -0
- package/src/utils/format.test.ts +76 -0
- package/src/utils/format.ts +67 -0
- package/src/utils/fs.ts +9 -0
- package/src/utils/markdown.test.ts +442 -0
- package/src/utils/markdown.ts +79 -0
- package/src/utils/messages.test.ts +436 -0
- package/src/utils/messages.ts +131 -0
- package/src/utils/model-ref.test.ts +42 -0
- package/src/utils/model-ref.ts +44 -0
- package/src/utils/state.test.ts +98 -0
- package/src/utils/state.ts +45 -0
- package/src/utils/tools.ts +48 -0
- package/src/utils/truncate.test.ts +41 -0
- package/src/utils/truncate.ts +59 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
|
|
5
|
+
import { CONFIG_DIR_NAME, getAgentDir } from '@earendil-works/pi-coding-agent'
|
|
6
|
+
|
|
7
|
+
import { loadMarkdown, type PromptDefinition } from '../utils/markdown.js'
|
|
8
|
+
import { composeTools } from '../utils/tools.js'
|
|
9
|
+
|
|
10
|
+
const DEFAULT_ROLE = 'worker'
|
|
11
|
+
|
|
12
|
+
const roles: Map<string, PromptDefinition> = new Map()
|
|
13
|
+
|
|
14
|
+
export async function loadMarkdownRolesFromDir(
|
|
15
|
+
dir: string,
|
|
16
|
+
): Promise<Map<string, PromptDefinition>> {
|
|
17
|
+
const loadedRoles = new Map<string, PromptDefinition>()
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
21
|
+
|
|
22
|
+
await Promise.all(
|
|
23
|
+
entries
|
|
24
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
|
|
25
|
+
.map(async (entry) => {
|
|
26
|
+
const roleName = entry.name.slice(0, -'.md'.length)
|
|
27
|
+
const definition = await loadMarkdown(join(dir, entry.name))
|
|
28
|
+
if (definition) {
|
|
29
|
+
loadedRoles.set(roleName, definition)
|
|
30
|
+
}
|
|
31
|
+
}),
|
|
32
|
+
)
|
|
33
|
+
} catch {
|
|
34
|
+
// Ignore unreadable directories silently
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return loadedRoles
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function loadBuiltins(): Promise<Map<string, PromptDefinition>> {
|
|
41
|
+
return new Map<string, PromptDefinition>([
|
|
42
|
+
[DEFAULT_ROLE, { fm: {}, systemPrompt: '' }],
|
|
43
|
+
])
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface LoadRolesOptions {
|
|
47
|
+
/** Skip roles bundled with the plugin (agents/ directory in the package root). */
|
|
48
|
+
skipPluginAgents?: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function loadRoles(
|
|
52
|
+
cwd: string,
|
|
53
|
+
options: LoadRolesOptions = {},
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
roles.clear()
|
|
56
|
+
const addRoles = (newRoles: Map<string, PromptDefinition>) => {
|
|
57
|
+
for (const [name, role] of newRoles) {
|
|
58
|
+
roles.set(name, role)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
addRoles(await loadBuiltins())
|
|
63
|
+
if (!options.skipPluginAgents) {
|
|
64
|
+
// Bundled agents live in the package root, next to src/.
|
|
65
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
66
|
+
addRoles(await loadMarkdownRolesFromDir(join(here, '../..', 'agents')))
|
|
67
|
+
}
|
|
68
|
+
addRoles(await loadMarkdownRolesFromDir(join(getAgentDir(), 'agents')))
|
|
69
|
+
addRoles(await loadMarkdownRolesFromDir(join(cwd, CONFIG_DIR_NAME, 'agents')))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function resolveRole(name: string = DEFAULT_ROLE): PromptDefinition {
|
|
73
|
+
if (roles.size === 0) {
|
|
74
|
+
throw new Error('Roles not loaded. Call loadRoles() first.')
|
|
75
|
+
}
|
|
76
|
+
const role = roles.get(name)
|
|
77
|
+
if (!role) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`Role "${name}" not found. Available roles: ${[...roles.keys()].join(', ')}`,
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
return role
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function listRoles(): string[] {
|
|
86
|
+
return [...roles.keys()]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function formatRoleEntry(
|
|
90
|
+
[name, role]: [string, PromptDefinition],
|
|
91
|
+
baseTools: readonly string[],
|
|
92
|
+
): string {
|
|
93
|
+
const parts: string[] = []
|
|
94
|
+
if (role.fm.description) {
|
|
95
|
+
parts.push(role.fm.description)
|
|
96
|
+
}
|
|
97
|
+
const effective = composeTools(baseTools, {
|
|
98
|
+
tools: role.fm.tools,
|
|
99
|
+
extraTools: role.fm.extraTools,
|
|
100
|
+
removeTools: role.fm.removeTools,
|
|
101
|
+
})
|
|
102
|
+
if (effective.length > 0) parts.push(`tools: ${effective.join(', ')}`)
|
|
103
|
+
if (parts.length > 0) return ` - ${name}: ${parts.join('; ')}`
|
|
104
|
+
return ` - ${name}`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function rolesDescription(baseTools: readonly string[] = []): string {
|
|
108
|
+
if (roles.size === 0) {
|
|
109
|
+
return ''
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return [...roles.entries()]
|
|
113
|
+
.map((e) => formatRoleEntry(e, baseTools))
|
|
114
|
+
.join('\n')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function clearRoles(): void {
|
|
118
|
+
roles.clear()
|
|
119
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { ActivityReporter, MAX_ACTIVITY_BYTES } from './activity.js'
|
|
4
|
+
import type { LiveSubagent } from './manager.js'
|
|
5
|
+
|
|
6
|
+
const makeSubagent = (id: number, messages: unknown[]): LiveSubagent =>
|
|
7
|
+
({
|
|
8
|
+
id,
|
|
9
|
+
title: `task-${id}`,
|
|
10
|
+
text: `task-${id}`,
|
|
11
|
+
status: 'running',
|
|
12
|
+
startedAt: Date.now() - 1000,
|
|
13
|
+
session: { messages: messages as never },
|
|
14
|
+
followUpCount: 0,
|
|
15
|
+
activeTools: [],
|
|
16
|
+
}) as unknown as LiveSubagent
|
|
17
|
+
|
|
18
|
+
const assistant = (text: string) =>
|
|
19
|
+
({
|
|
20
|
+
role: 'assistant',
|
|
21
|
+
content: [{ type: 'text', text }],
|
|
22
|
+
timestamp: Date.now(),
|
|
23
|
+
}) as unknown as never
|
|
24
|
+
|
|
25
|
+
describe('ActivityReporter.formatActivityReport', () => {
|
|
26
|
+
it('returns last assistant message text', () => {
|
|
27
|
+
const messages = [assistant('Working on it')]
|
|
28
|
+
const subagent = makeSubagent(1, messages)
|
|
29
|
+
const report = ActivityReporter.formatActivityReport(subagent)
|
|
30
|
+
expect(report).toBe('Working on it')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('returns fallback for subagent with no valid last message', () => {
|
|
34
|
+
const subagent = makeSubagent(1, [])
|
|
35
|
+
const report = ActivityReporter.formatActivityReport(subagent)
|
|
36
|
+
expect(report).toBe('(Just started, waiting for first message)')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('truncates long message text to MAX_ACTIVITY_BYTES', () => {
|
|
40
|
+
const longText = 'a'.repeat(MAX_ACTIVITY_BYTES * 2)
|
|
41
|
+
const messages = [assistant(longText)]
|
|
42
|
+
const subagent = makeSubagent(1, messages)
|
|
43
|
+
const report = ActivityReporter.formatActivityReport(subagent)
|
|
44
|
+
expect(report).toContain('[Output truncated')
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('ActivityReporter integration', () => {
|
|
49
|
+
it('starts and stops without errors', () => {
|
|
50
|
+
vi.useFakeTimers()
|
|
51
|
+
let reportCount = 0
|
|
52
|
+
|
|
53
|
+
const activityReporter = new ActivityReporter({
|
|
54
|
+
list: () => [],
|
|
55
|
+
onActivity: () => {
|
|
56
|
+
reportCount += 1
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
activityReporter.start()
|
|
61
|
+
activityReporter.stop()
|
|
62
|
+
vi.advanceTimersByTime(5 * 60 * 1000)
|
|
63
|
+
|
|
64
|
+
expect(reportCount).toBe(0)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('does not call onActivity when no running subagents', () => {
|
|
68
|
+
vi.useFakeTimers()
|
|
69
|
+
const reports: { subagent: LiveSubagent; report: string }[] = []
|
|
70
|
+
|
|
71
|
+
const activityReporter = new ActivityReporter({
|
|
72
|
+
list: () =>
|
|
73
|
+
[
|
|
74
|
+
{ id: 1, status: 'done', title: 'Task 1' },
|
|
75
|
+
{ id: 2, status: 'failed', title: 'Task 2' },
|
|
76
|
+
] as LiveSubagent[],
|
|
77
|
+
onActivity: (subagent, report) => reports.push({ subagent, report }),
|
|
78
|
+
checkIntervalMs: 100,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
activityReporter.start()
|
|
82
|
+
vi.advanceTimersByTime(100)
|
|
83
|
+
|
|
84
|
+
expect(reports).toHaveLength(0)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('calls onActivity with subagent and report when running subagents exist', () => {
|
|
88
|
+
vi.useFakeTimers()
|
|
89
|
+
const reports: { subagent: LiveSubagent; report: string }[] = []
|
|
90
|
+
const subagent = makeSubagent(1, [assistant('Doing task')])
|
|
91
|
+
subagent.startedAt = Date.now() - 60000
|
|
92
|
+
|
|
93
|
+
const activityReporter = new ActivityReporter({
|
|
94
|
+
list: () => [subagent],
|
|
95
|
+
onActivity: (subagent, report) => reports.push({ subagent, report }),
|
|
96
|
+
checkIntervalMs: 100,
|
|
97
|
+
notificationIntervalMs: 1000,
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
activityReporter.start()
|
|
101
|
+
vi.advanceTimersByTime(100)
|
|
102
|
+
|
|
103
|
+
expect(reports).toHaveLength(1)
|
|
104
|
+
expect(reports[0]?.subagent.id).toBe(1)
|
|
105
|
+
expect(reports[0]?.report).toBe('Doing task')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('respects custom checkIntervalMs', () => {
|
|
109
|
+
vi.useFakeTimers()
|
|
110
|
+
const reports: { subagent: LiveSubagent; report: string }[] = []
|
|
111
|
+
const subagent = makeSubagent(1, [])
|
|
112
|
+
subagent.startedAt = Date.now() - 60000
|
|
113
|
+
|
|
114
|
+
const activityReporter = new ActivityReporter({
|
|
115
|
+
list: () => [subagent],
|
|
116
|
+
onActivity: (subagent, report) => reports.push({ subagent, report }),
|
|
117
|
+
checkIntervalMs: 200,
|
|
118
|
+
notificationIntervalMs: 1000,
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
activityReporter.start()
|
|
122
|
+
vi.advanceTimersByTime(199)
|
|
123
|
+
expect(reports).toHaveLength(0)
|
|
124
|
+
|
|
125
|
+
vi.advanceTimersByTime(1)
|
|
126
|
+
expect(reports).toHaveLength(1)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('does not leak timers on multiple starts', () => {
|
|
130
|
+
vi.useFakeTimers()
|
|
131
|
+
let reportCount = 0
|
|
132
|
+
const subagent = makeSubagent(1, [])
|
|
133
|
+
subagent.startedAt = Date.now() - 60000
|
|
134
|
+
|
|
135
|
+
const activityReporter = new ActivityReporter({
|
|
136
|
+
list: () => [subagent],
|
|
137
|
+
onActivity: () => {
|
|
138
|
+
reportCount += 1
|
|
139
|
+
},
|
|
140
|
+
checkIntervalMs: 50,
|
|
141
|
+
notificationIntervalMs: 100,
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
activityReporter.start()
|
|
145
|
+
vi.advanceTimersByTime(50)
|
|
146
|
+
expect(reportCount).toBe(1)
|
|
147
|
+
|
|
148
|
+
vi.advanceTimersByTime(50)
|
|
149
|
+
expect(reportCount).toBe(1)
|
|
150
|
+
|
|
151
|
+
activityReporter.start()
|
|
152
|
+
vi.advanceTimersByTime(50)
|
|
153
|
+
expect(reportCount).toBe(2)
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
describe('ActivityReporter per-subagent throttling', () => {
|
|
158
|
+
it('throttles notifications per subagent based on notificationIntervalMs', () => {
|
|
159
|
+
vi.useFakeTimers()
|
|
160
|
+
const reports: { subagent: LiveSubagent; report: string }[] = []
|
|
161
|
+
const subagent = makeSubagent(1, [])
|
|
162
|
+
subagent.startedAt = Date.now() - 60000
|
|
163
|
+
|
|
164
|
+
const activityReporter = new ActivityReporter({
|
|
165
|
+
list: () => [subagent],
|
|
166
|
+
onActivity: (subagent, report) => reports.push({ subagent, report }),
|
|
167
|
+
checkIntervalMs: 100,
|
|
168
|
+
notificationIntervalMs: 200,
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
activityReporter.start()
|
|
172
|
+
vi.advanceTimersByTime(100)
|
|
173
|
+
expect(reports).toHaveLength(1)
|
|
174
|
+
|
|
175
|
+
vi.advanceTimersByTime(100)
|
|
176
|
+
expect(reports).toHaveLength(1)
|
|
177
|
+
|
|
178
|
+
vi.advanceTimersByTime(100)
|
|
179
|
+
expect(reports).toHaveLength(2)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('tracks per-subagent throttling independently', () => {
|
|
183
|
+
vi.useFakeTimers()
|
|
184
|
+
const reports: { subagent: LiveSubagent; report: string }[] = []
|
|
185
|
+
const s1 = makeSubagent(1, [])
|
|
186
|
+
s1.startedAt = Date.now() - 60000
|
|
187
|
+
const s2 = makeSubagent(2, [])
|
|
188
|
+
s2.startedAt = Date.now() - 60000
|
|
189
|
+
|
|
190
|
+
const activityReporter = new ActivityReporter({
|
|
191
|
+
list: () => [s1, s2],
|
|
192
|
+
onActivity: (subagent, report) => reports.push({ subagent, report }),
|
|
193
|
+
checkIntervalMs: 100,
|
|
194
|
+
notificationIntervalMs: 200,
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
activityReporter.start()
|
|
198
|
+
vi.advanceTimersByTime(100)
|
|
199
|
+
expect(reports).toHaveLength(2)
|
|
200
|
+
|
|
201
|
+
s1.status = 'done'
|
|
202
|
+
vi.advanceTimersByTime(100)
|
|
203
|
+
expect(reports).toHaveLength(2)
|
|
204
|
+
|
|
205
|
+
vi.advanceTimersByTime(100)
|
|
206
|
+
expect(reports).toHaveLength(3)
|
|
207
|
+
expect(reports[2]?.subagent.id).toBe(2)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('does not send report when subagent is not due yet', () => {
|
|
211
|
+
vi.useFakeTimers()
|
|
212
|
+
const reports: { subagent: LiveSubagent; report: string }[] = []
|
|
213
|
+
const subagent = makeSubagent(1, [])
|
|
214
|
+
subagent.startedAt = Date.now() - 1000
|
|
215
|
+
|
|
216
|
+
const activityReporter = new ActivityReporter({
|
|
217
|
+
list: () => [subagent],
|
|
218
|
+
onActivity: (subagent, report) => reports.push({ subagent, report }),
|
|
219
|
+
checkIntervalMs: 100,
|
|
220
|
+
notificationIntervalMs: 1500,
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
activityReporter.start()
|
|
224
|
+
vi.advanceTimersByTime(100)
|
|
225
|
+
expect(reports).toHaveLength(0)
|
|
226
|
+
|
|
227
|
+
vi.advanceTimersByTime(400)
|
|
228
|
+
expect(reports).toHaveLength(1)
|
|
229
|
+
})
|
|
230
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { lastMessageText } from '../utils/messages.js'
|
|
2
|
+
import type { LiveSubagent } from './manager.js'
|
|
3
|
+
|
|
4
|
+
const CHECK_INTERVAL_MS = 60 * 1000
|
|
5
|
+
const NOTIFICATION_INTERVAL_MS = 5 * 60 * 1000
|
|
6
|
+
export const MAX_ACTIVITY_BYTES = 1000
|
|
7
|
+
|
|
8
|
+
export class ActivityReporter {
|
|
9
|
+
private timer: ReturnType<typeof setInterval> | undefined
|
|
10
|
+
private lastSentAt = new Map<number, number>()
|
|
11
|
+
|
|
12
|
+
constructor(
|
|
13
|
+
private readonly options: {
|
|
14
|
+
list: () => readonly LiveSubagent[]
|
|
15
|
+
onActivity: (subagent: LiveSubagent, report: string) => void
|
|
16
|
+
checkIntervalMs?: number
|
|
17
|
+
notificationIntervalMs?: number
|
|
18
|
+
},
|
|
19
|
+
) {}
|
|
20
|
+
|
|
21
|
+
start(): void {
|
|
22
|
+
this.stop()
|
|
23
|
+
const intervalMs = this.options.checkIntervalMs ?? CHECK_INTERVAL_MS
|
|
24
|
+
this.timer = setInterval(() => {
|
|
25
|
+
this.tick()
|
|
26
|
+
}, intervalMs)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
stop(): void {
|
|
30
|
+
if (this.timer !== undefined) {
|
|
31
|
+
clearInterval(this.timer)
|
|
32
|
+
this.timer = undefined
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private tick(): void {
|
|
37
|
+
const now = Date.now()
|
|
38
|
+
const notificationMs =
|
|
39
|
+
this.options.notificationIntervalMs ?? NOTIFICATION_INTERVAL_MS
|
|
40
|
+
const runningList = this.options
|
|
41
|
+
.list()
|
|
42
|
+
.filter((w) => w.status === 'running')
|
|
43
|
+
for (const subagent of runningList) {
|
|
44
|
+
const last = this.lastSentAt.get(subagent.id) ?? subagent.startedAt
|
|
45
|
+
if (now - last < notificationMs) continue
|
|
46
|
+
this.options.onActivity(
|
|
47
|
+
subagent,
|
|
48
|
+
ActivityReporter.formatActivityReport(subagent),
|
|
49
|
+
)
|
|
50
|
+
this.lastSentAt.set(subagent.id, now)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
static formatActivityReport(subagent: LiveSubagent): string {
|
|
55
|
+
return (
|
|
56
|
+
lastMessageText(subagent.session.messages, MAX_ACTIVITY_BYTES) ??
|
|
57
|
+
'(Just started, waiting for first message)'
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { MessageBatcher } from './batcher.js'
|
|
4
|
+
import type { LiveSubagent } from './manager.js'
|
|
5
|
+
|
|
6
|
+
const makeSubagent = (
|
|
7
|
+
id: number,
|
|
8
|
+
title: string,
|
|
9
|
+
status: string,
|
|
10
|
+
): LiveSubagent =>
|
|
11
|
+
({
|
|
12
|
+
id,
|
|
13
|
+
title,
|
|
14
|
+
text: title,
|
|
15
|
+
status: status as never,
|
|
16
|
+
startedAt: Date.now() - 5000,
|
|
17
|
+
session: { messages: [] as never },
|
|
18
|
+
followUpCount: 0,
|
|
19
|
+
activeTools: [],
|
|
20
|
+
}) as unknown as LiveSubagent
|
|
21
|
+
|
|
22
|
+
describe('MessageBatcher', () => {
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.useRealTimers()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('flushes after batching window', () => {
|
|
28
|
+
vi.useFakeTimers()
|
|
29
|
+
const flushed: string[][] = []
|
|
30
|
+
const batcher = new MessageBatcher((items) => flushed.push(items), 100)
|
|
31
|
+
const subagent = makeSubagent(1, 'task-1', 'done')
|
|
32
|
+
subagent.contextUsage = {
|
|
33
|
+
tokens: 60000,
|
|
34
|
+
contextWindow: 200000,
|
|
35
|
+
percent: 30,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
batcher.add(subagent, 'done', 'Task completed')
|
|
39
|
+
expect(batcher.pending.length).toBe(1)
|
|
40
|
+
|
|
41
|
+
vi.advanceTimersByTime(99)
|
|
42
|
+
expect(flushed).toHaveLength(0)
|
|
43
|
+
|
|
44
|
+
vi.advanceTimersByTime(1)
|
|
45
|
+
expect(flushed).toHaveLength(1)
|
|
46
|
+
const firstFlush = flushed[0]
|
|
47
|
+
expect(firstFlush?.[0]).toContain('done #1')
|
|
48
|
+
expect(firstFlush?.[0]).toContain('60k/200k')
|
|
49
|
+
expect(firstFlush?.[0]).toContain('<type>done</type>')
|
|
50
|
+
expect(firstFlush?.[0]).toContain('<message>Task completed</message>')
|
|
51
|
+
expect(batcher.pending).toEqual([])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('combines multiple items in the same window', () => {
|
|
55
|
+
vi.useFakeTimers()
|
|
56
|
+
const flushed: string[][] = []
|
|
57
|
+
const batcher = new MessageBatcher((items) => flushed.push(items), 100)
|
|
58
|
+
|
|
59
|
+
const subagent1 = makeSubagent(1, 'task-1', 'done')
|
|
60
|
+
const subagent2 = makeSubagent(2, 'task-2', 'failed')
|
|
61
|
+
|
|
62
|
+
batcher.add(subagent1, 'done', 'Task 1 completed')
|
|
63
|
+
vi.advanceTimersByTime(50)
|
|
64
|
+
batcher.add(subagent2, 'failed', 'Task 2 failed')
|
|
65
|
+
vi.advanceTimersByTime(100)
|
|
66
|
+
|
|
67
|
+
expect(flushed).toHaveLength(1)
|
|
68
|
+
const firstFlush = flushed[0]
|
|
69
|
+
expect(firstFlush?.length).toBe(2)
|
|
70
|
+
expect(firstFlush?.[0]).toContain('done #1')
|
|
71
|
+
expect(firstFlush?.[0]).toContain('Task 1 completed')
|
|
72
|
+
expect(firstFlush?.[1]).toContain('failed #2')
|
|
73
|
+
expect(firstFlush?.[1]).toContain('Task 2 failed')
|
|
74
|
+
expect(batcher.pending).toEqual([])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('clears pending items after flushNow', () => {
|
|
78
|
+
vi.useFakeTimers()
|
|
79
|
+
const flushed: string[][] = []
|
|
80
|
+
const batcher = new MessageBatcher((items) => flushed.push(items), 100)
|
|
81
|
+
|
|
82
|
+
const subagent1 = makeSubagent(1, 'task-1', 'done')
|
|
83
|
+
batcher.add(subagent1, 'done', 'Task 1 completed')
|
|
84
|
+
batcher.flushNow()
|
|
85
|
+
expect(batcher.pending).toEqual([])
|
|
86
|
+
|
|
87
|
+
const subagent2 = makeSubagent(2, 'task-2', 'done')
|
|
88
|
+
batcher.add(subagent2, 'done', 'Task 2 completed')
|
|
89
|
+
batcher.flushNow()
|
|
90
|
+
|
|
91
|
+
expect(flushed).toHaveLength(2)
|
|
92
|
+
expect(flushed[0]?.[0]).toContain('done #1')
|
|
93
|
+
expect(flushed[1]?.[0]).toContain('done #2')
|
|
94
|
+
expect(batcher.pending).toEqual([])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('flushes immediately when requested via flushNow', () => {
|
|
98
|
+
vi.useFakeTimers()
|
|
99
|
+
const flushed: string[][] = []
|
|
100
|
+
const batcher = new MessageBatcher((items) => flushed.push(items), 100)
|
|
101
|
+
|
|
102
|
+
const subagent = makeSubagent(1, 'task-1', 'done')
|
|
103
|
+
batcher.add(subagent, 'done', 'Task completed')
|
|
104
|
+
batcher.flushNow()
|
|
105
|
+
|
|
106
|
+
expect(flushed).toHaveLength(1)
|
|
107
|
+
const firstFlush = flushed[0]
|
|
108
|
+
expect(firstFlush?.[0]).toContain('done #1')
|
|
109
|
+
vi.advanceTimersByTime(100)
|
|
110
|
+
expect(flushed).toHaveLength(1)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('clears pending items without flushing via clear', () => {
|
|
114
|
+
vi.useFakeTimers()
|
|
115
|
+
const flushed: string[][] = []
|
|
116
|
+
const batcher = new MessageBatcher((items) => flushed.push(items), 100)
|
|
117
|
+
|
|
118
|
+
const subagent = makeSubagent(1, 'task-1', 'done')
|
|
119
|
+
batcher.add(subagent, 'done', 'Task completed')
|
|
120
|
+
batcher.clear()
|
|
121
|
+
vi.advanceTimersByTime(100)
|
|
122
|
+
|
|
123
|
+
expect(flushed).toHaveLength(0)
|
|
124
|
+
expect(batcher.pending).toEqual([])
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('adds items in order and flushes them together', () => {
|
|
128
|
+
vi.useFakeTimers()
|
|
129
|
+
const flushed: string[][] = []
|
|
130
|
+
const batcher = new MessageBatcher((items) => flushed.push(items), 100)
|
|
131
|
+
|
|
132
|
+
const subagent1 = makeSubagent(1, 'task-1', 'done')
|
|
133
|
+
const subagent2 = makeSubagent(2, 'task-2', 'running')
|
|
134
|
+
|
|
135
|
+
batcher.add(subagent1, 'done', 'Subagent #1 work done.')
|
|
136
|
+
batcher.add(subagent2, 'activity', 'Subagent activity update')
|
|
137
|
+
vi.advanceTimersByTime(100)
|
|
138
|
+
|
|
139
|
+
expect(flushed).toHaveLength(1)
|
|
140
|
+
const firstFlush = flushed[0]
|
|
141
|
+
expect(firstFlush?.length).toBe(2)
|
|
142
|
+
expect(firstFlush?.[0]).toContain('done #1')
|
|
143
|
+
expect(firstFlush?.[0]).toContain('Subagent #1 work done.')
|
|
144
|
+
expect(firstFlush?.[1]).toContain('running #2')
|
|
145
|
+
expect(firstFlush?.[1]).toContain('Subagent activity update')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('resets timer on each add', () => {
|
|
149
|
+
vi.useFakeTimers()
|
|
150
|
+
const flushed: string[][] = []
|
|
151
|
+
const batcher = new MessageBatcher((items) => flushed.push(items), 100)
|
|
152
|
+
|
|
153
|
+
const subagent1 = makeSubagent(1, 'task-1', 'done')
|
|
154
|
+
const subagent2 = makeSubagent(2, 'task-2', 'running')
|
|
155
|
+
|
|
156
|
+
batcher.add(subagent1, 'done', 'First message')
|
|
157
|
+
vi.advanceTimersByTime(50)
|
|
158
|
+
expect(flushed).toHaveLength(0)
|
|
159
|
+
|
|
160
|
+
batcher.add(subagent2, 'activity', 'Second message')
|
|
161
|
+
vi.advanceTimersByTime(50)
|
|
162
|
+
expect(flushed).toHaveLength(0)
|
|
163
|
+
|
|
164
|
+
vi.advanceTimersByTime(50)
|
|
165
|
+
expect(flushed).toHaveLength(1)
|
|
166
|
+
|
|
167
|
+
const firstFlush = flushed[0]
|
|
168
|
+
expect(firstFlush?.length).toBe(2)
|
|
169
|
+
expect(firstFlush?.[0]).toContain('done #1')
|
|
170
|
+
expect(firstFlush?.[0]).toContain('First message')
|
|
171
|
+
expect(firstFlush?.[1]).toContain('running #2')
|
|
172
|
+
expect(firstFlush?.[1]).toContain('Second message')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('clears old timer and starts new one when adding item', () => {
|
|
176
|
+
vi.useFakeTimers()
|
|
177
|
+
const flushed: string[][] = []
|
|
178
|
+
const batcher = new MessageBatcher((items) => flushed.push(items), 100)
|
|
179
|
+
|
|
180
|
+
const subagent = makeSubagent(1, 'task-1', 'done')
|
|
181
|
+
|
|
182
|
+
batcher.add(subagent, 'done', 'Message 1')
|
|
183
|
+
vi.advanceTimersByTime(80)
|
|
184
|
+
expect(flushed).toHaveLength(0)
|
|
185
|
+
|
|
186
|
+
batcher.add(subagent, 'activity', 'Message 2')
|
|
187
|
+
vi.advanceTimersByTime(80)
|
|
188
|
+
expect(flushed).toHaveLength(0)
|
|
189
|
+
|
|
190
|
+
vi.advanceTimersByTime(20)
|
|
191
|
+
expect(flushed).toHaveLength(1)
|
|
192
|
+
|
|
193
|
+
const firstFlush = flushed[0]
|
|
194
|
+
expect(firstFlush?.length).toBe(2)
|
|
195
|
+
expect(firstFlush?.[0]).toContain('Message 1')
|
|
196
|
+
expect(firstFlush?.[1]).toContain('Message 2')
|
|
197
|
+
})
|
|
198
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { formatSubagentSummary, type LiveSubagent } from './manager.js'
|
|
2
|
+
|
|
3
|
+
export class MessageBatcher {
|
|
4
|
+
private items: string[] = []
|
|
5
|
+
private timer: ReturnType<typeof setTimeout> | undefined
|
|
6
|
+
|
|
7
|
+
constructor(
|
|
8
|
+
private readonly flush: (items: string[]) => void,
|
|
9
|
+
private readonly windowMs = 3000,
|
|
10
|
+
) {}
|
|
11
|
+
|
|
12
|
+
get pending(): readonly string[] {
|
|
13
|
+
return [...this.items]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
add(subagent: LiveSubagent, type: string, message: string): void {
|
|
17
|
+
const tagName = type === 'done' ? 'subagent-done' : 'subagent-notify'
|
|
18
|
+
const item = [
|
|
19
|
+
`<${tagName}>`,
|
|
20
|
+
`<type>${type}</type>`,
|
|
21
|
+
`<job>${formatSubagentSummary(subagent)}</job>`,
|
|
22
|
+
`<message>${message}</message>`,
|
|
23
|
+
`</${tagName}>`,
|
|
24
|
+
].join('\n')
|
|
25
|
+
this.items.push(item)
|
|
26
|
+
if (this.timer !== undefined) clearTimeout(this.timer)
|
|
27
|
+
this.timer = setTimeout(() => {
|
|
28
|
+
this.flushNow()
|
|
29
|
+
}, this.windowMs)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
flushNow(): void {
|
|
33
|
+
if (this.timer !== undefined) {
|
|
34
|
+
clearTimeout(this.timer)
|
|
35
|
+
this.timer = undefined
|
|
36
|
+
}
|
|
37
|
+
if (this.items.length === 0) return
|
|
38
|
+
|
|
39
|
+
const items = this.items
|
|
40
|
+
this.items = []
|
|
41
|
+
this.flush(items)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
clear(): void {
|
|
45
|
+
if (this.timer !== undefined) {
|
|
46
|
+
clearTimeout(this.timer)
|
|
47
|
+
this.timer = undefined
|
|
48
|
+
}
|
|
49
|
+
this.items = []
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const FOLLOW_SYMBOL = '⟳'
|