pi-roundtable 0.6.1 → 0.7.2
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/CHANGELOG.md +44 -0
- package/docs/plugins.md +153 -10
- package/examples/reply-files.test.ts +34 -0
- package/examples/reply-files.ts +27 -0
- package/package.json +10 -3
- package/src/cli/cli.ts +1 -1
- package/src/cli/templates.ts +5 -1
- package/src/core/agents/team-turns.ts +22 -12
- package/src/core/contract/surface.ts +2 -0
- package/src/core/define.ts +5 -0
- package/src/core/discord/discord-surface.ts +1 -0
- package/src/core/domain/conversation.ts +8 -2
- package/src/core/reply-files.ts +107 -0
- package/src/core/routing/conversation-turns.ts +20 -14
- package/src/core/routing/surface-port.ts +8 -2
- package/src/core/runtime/pi-agent-runtime.ts +2 -1
- package/src/core/runtime/turn-answer.ts +2 -1
- package/src/index.ts +6 -0
- package/src/testing.ts +34 -4
- package/templates/biome.json.tmpl +1 -1
- package/templates/official/release-notice/plugin.test.ts.tmpl +248 -0
- package/templates/official/release-notice/plugin.ts +194 -0
- package/templates/package.json.tmpl +1 -1
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import type { ReplyFile, TurnResult } from "./domain/conversation.ts";
|
|
3
|
+
|
|
4
|
+
/** Limits across all tools in one turn, independent of the surface's own limits. */
|
|
5
|
+
export const REPLY_FILE_LIMITS = Object.freeze({
|
|
6
|
+
maxFiles: 10,
|
|
7
|
+
maxFileBytes: 10 * 1024 * 1024,
|
|
8
|
+
maxTotalBytes: 50 * 1024 * 1024,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
/** An attachment was refused; its message names the reason instead of dropping the file. */
|
|
12
|
+
export class ReplyFileError extends Error {
|
|
13
|
+
override name = "ReplyFileError";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ReplyFiles {
|
|
17
|
+
active: boolean;
|
|
18
|
+
supported: boolean;
|
|
19
|
+
files: ReplyFile[];
|
|
20
|
+
bytes: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const current = new AsyncLocalStorage<ReplyFiles | undefined>();
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Queues a copy of a file for the current turn's successful reply. Session tools and Pi package
|
|
27
|
+
* tools may call this directly; outside a supported running turn it throws ReplyFileError.
|
|
28
|
+
*/
|
|
29
|
+
export function attachReplyFile(file: ReplyFile): void {
|
|
30
|
+
const turn = current.getStore();
|
|
31
|
+
if (!turn?.active)
|
|
32
|
+
throw new ReplyFileError(
|
|
33
|
+
"attachReplyFile requires a running conversation turn; await it inside the turn's tool call.",
|
|
34
|
+
);
|
|
35
|
+
if (!turn.supported)
|
|
36
|
+
throw new ReplyFileError(
|
|
37
|
+
"this turn's chat surface does not support reply files.",
|
|
38
|
+
);
|
|
39
|
+
if (
|
|
40
|
+
!file ||
|
|
41
|
+
typeof file.name !== "string" ||
|
|
42
|
+
!file.name.trim() ||
|
|
43
|
+
file.name.length > 255 ||
|
|
44
|
+
/[\\/]/.test(file.name) ||
|
|
45
|
+
[...file.name].some(
|
|
46
|
+
(char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127,
|
|
47
|
+
) ||
|
|
48
|
+
file.name === "." ||
|
|
49
|
+
file.name === ".."
|
|
50
|
+
)
|
|
51
|
+
throw new ReplyFileError(
|
|
52
|
+
"a reply file needs a non-empty filename of at most 255 characters, without paths or control characters.",
|
|
53
|
+
);
|
|
54
|
+
if (!(file.data instanceof Uint8Array) || file.data.byteLength === 0)
|
|
55
|
+
throw new ReplyFileError("a reply file needs non-empty Uint8Array data.");
|
|
56
|
+
if (turn.files.length >= REPLY_FILE_LIMITS.maxFiles)
|
|
57
|
+
throw new ReplyFileError(
|
|
58
|
+
`a turn may attach at most ${REPLY_FILE_LIMITS.maxFiles} reply files.`,
|
|
59
|
+
);
|
|
60
|
+
if (file.data.byteLength > REPLY_FILE_LIMITS.maxFileBytes)
|
|
61
|
+
throw new ReplyFileError(
|
|
62
|
+
`a reply file may contain at most ${REPLY_FILE_LIMITS.maxFileBytes} bytes.`,
|
|
63
|
+
);
|
|
64
|
+
if (turn.bytes + file.data.byteLength > REPLY_FILE_LIMITS.maxTotalBytes)
|
|
65
|
+
throw new ReplyFileError(
|
|
66
|
+
`a turn's reply files may contain at most ${REPLY_FILE_LIMITS.maxTotalBytes} bytes in total.`,
|
|
67
|
+
);
|
|
68
|
+
turn.files.push({ name: file.name, data: new Uint8Array(file.data) });
|
|
69
|
+
turn.bytes += file.data.byteLength;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Used by test fixtures to forward attachments only when called inside a turn. */
|
|
73
|
+
export function hasReplyFileScope(): boolean {
|
|
74
|
+
return current.getStore()?.active === true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Whether an otherwise empty final answer has files to show. */
|
|
78
|
+
export function hasReplyFiles(): boolean {
|
|
79
|
+
const turn = current.getStore();
|
|
80
|
+
return turn?.active === true && turn.files.length > 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One isolated collector per turn, also when turns run concurrently or nest. */
|
|
84
|
+
export async function withReplyFiles(
|
|
85
|
+
supported: boolean,
|
|
86
|
+
run: () => Promise<TurnResult>,
|
|
87
|
+
): Promise<TurnResult> {
|
|
88
|
+
const turn: ReplyFiles = { active: true, supported, files: [], bytes: 0 };
|
|
89
|
+
return current.run(turn, async () => {
|
|
90
|
+
try {
|
|
91
|
+
const result = await run();
|
|
92
|
+
if (!result.ok) return result;
|
|
93
|
+
// A replacement runtime may produce files in its result rather than calling the helper.
|
|
94
|
+
for (const file of result.files ?? []) attachReplyFile(file);
|
|
95
|
+
return turn.files.length ? { ...result, files: [...turn.files] } : result;
|
|
96
|
+
} finally {
|
|
97
|
+
turn.active = false;
|
|
98
|
+
turn.files = [];
|
|
99
|
+
turn.bytes = 0;
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A transient task reports text, not a conversation reply: it cannot borrow its parent's collector. */
|
|
105
|
+
export function withoutReplyFiles<T>(run: () => Promise<T>): Promise<T> {
|
|
106
|
+
return current.run(undefined, run);
|
|
107
|
+
}
|
|
@@ -7,6 +7,7 @@ import type { Logger } from "../log.ts";
|
|
|
7
7
|
import type { EventSink } from "../plugin.ts";
|
|
8
8
|
import { splitReply } from "../presentation/reply-splitter.ts";
|
|
9
9
|
import { thinkingLine } from "../presentation/thinking-line.ts";
|
|
10
|
+
import { withReplyFiles } from "../reply-files.ts";
|
|
10
11
|
import type { ChannelKey, ToolSelection, TurnSelection } from "../sessions.ts";
|
|
11
12
|
import type { Speaker } from "../speakers.ts";
|
|
12
13
|
import { endOf, settleTurn } from "./settle-turn.ts";
|
|
@@ -85,20 +86,24 @@ export function conversationTurns(
|
|
|
85
86
|
try {
|
|
86
87
|
result = await settleTurn(
|
|
87
88
|
() =>
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
89
|
+
withReplyFiles(surfaces.of(channel)?.supportsFiles === true, () =>
|
|
90
|
+
runtime.runTurn({
|
|
91
|
+
channel,
|
|
92
|
+
kind,
|
|
93
|
+
selection: input.selection ?? {
|
|
94
|
+
id: DEFAULT_SELECTION,
|
|
95
|
+
...options.selection(),
|
|
96
|
+
},
|
|
97
|
+
text: input.text,
|
|
98
|
+
speaker,
|
|
99
|
+
...(input.attachments
|
|
100
|
+
? { attachments: input.attachments }
|
|
101
|
+
: {}),
|
|
102
|
+
...(input.confirmed ? { confirmed: true } : {}),
|
|
103
|
+
...(input.steerable ? { steerable: true } : {}),
|
|
104
|
+
...(input.interactive ? { interactive: true } : {}),
|
|
105
|
+
}),
|
|
106
|
+
),
|
|
102
107
|
"conversation turn",
|
|
103
108
|
);
|
|
104
109
|
} finally {
|
|
@@ -135,5 +140,6 @@ function replyOf(result: TurnResult) {
|
|
|
135
140
|
return {
|
|
136
141
|
...(thinking ? { thinking } : {}),
|
|
137
142
|
chunks: splitReply(result.text),
|
|
143
|
+
...(result.files?.length ? { files: result.files } : {}),
|
|
138
144
|
};
|
|
139
145
|
}
|
|
@@ -24,8 +24,14 @@ export function surfacePort(linked: () => readonly ChatSurface[]): SurfacePort {
|
|
|
24
24
|
};
|
|
25
25
|
return {
|
|
26
26
|
of,
|
|
27
|
-
sendReply: async (channel, reply) =>
|
|
28
|
-
served(channel)
|
|
27
|
+
sendReply: async (channel, reply) => {
|
|
28
|
+
const surface = served(channel);
|
|
29
|
+
if (reply.files?.length && surface.supportsFiles !== true)
|
|
30
|
+
throw new PluginError(
|
|
31
|
+
`surface ${surface.surface} does not support reply files; declare supportsFiles: true and deliver every file in sendReply.`,
|
|
32
|
+
);
|
|
33
|
+
await surface.sendReply(channel, reply);
|
|
34
|
+
},
|
|
29
35
|
startTyping: (channel) =>
|
|
30
36
|
of(channel)?.startTyping?.(channel) ?? (() => undefined),
|
|
31
37
|
showStop: (channel) =>
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
type ThinkingLevel,
|
|
25
25
|
type ThinkingSetting,
|
|
26
26
|
} from "../models.ts";
|
|
27
|
+
import { withoutReplyFiles } from "../reply-files.ts";
|
|
27
28
|
import { planOrder, type TransientTask } from "../sessions.ts";
|
|
28
29
|
import { textOf } from "../shared/session-messages.ts";
|
|
29
30
|
import { addressee, type Speaker, type Tier } from "../speakers.ts";
|
|
@@ -352,7 +353,7 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
352
353
|
this.#tiers.allows(tier, name),
|
|
353
354
|
);
|
|
354
355
|
session.setActiveToolsByName([...worker.tools]);
|
|
355
|
-
await session.prompt(task.text);
|
|
356
|
+
await withoutReplyFiles(() => session.prompt(task.text));
|
|
356
357
|
return workerReport(
|
|
357
358
|
session.messages,
|
|
358
359
|
"the worker stopped before it reported",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { TurnResult } from "../domain/conversation.ts";
|
|
2
2
|
import { AgentRunError } from "../domain/errors.ts";
|
|
3
|
+
import { hasReplyFiles } from "../reply-files.ts";
|
|
3
4
|
import { lastAssistant, textOf } from "../shared/session-messages.ts";
|
|
4
5
|
|
|
5
6
|
interface MessagePart {
|
|
@@ -67,7 +68,7 @@ export function turnAnswer(
|
|
|
67
68
|
.join("\n\n")
|
|
68
69
|
: textOf(last.message.content)
|
|
69
70
|
).trim();
|
|
70
|
-
if (!text)
|
|
71
|
+
if (!text && !hasReplyFiles())
|
|
71
72
|
return {
|
|
72
73
|
ok: false,
|
|
73
74
|
error: new AgentRunError("the final assistant message has no text"),
|
package/src/index.ts
CHANGED
|
@@ -77,6 +77,7 @@ export type {
|
|
|
77
77
|
HeldCall,
|
|
78
78
|
OutboundReply,
|
|
79
79
|
PendingConfirmation,
|
|
80
|
+
ReplyFile,
|
|
80
81
|
TranscriptEntry,
|
|
81
82
|
TurnResult,
|
|
82
83
|
} from "./core/domain/conversation.ts";
|
|
@@ -157,6 +158,11 @@ export type {
|
|
|
157
158
|
TurnEndEvent,
|
|
158
159
|
TurnEvent,
|
|
159
160
|
} from "./core/plugin.ts";
|
|
161
|
+
export {
|
|
162
|
+
attachReplyFile,
|
|
163
|
+
REPLY_FILE_LIMITS,
|
|
164
|
+
ReplyFileError,
|
|
165
|
+
} from "./core/reply-files.ts";
|
|
160
166
|
export type {
|
|
161
167
|
ConversationTurnInput,
|
|
162
168
|
ConversationTurns,
|
package/src/testing.ts
CHANGED
|
@@ -25,7 +25,10 @@ import type {
|
|
|
25
25
|
InteractionContribution,
|
|
26
26
|
} from "./core/discord/interaction-module.ts";
|
|
27
27
|
import { OwnerGuard, ownerRootCommand } from "./core/discord/owner-command.ts";
|
|
28
|
-
import type {
|
|
28
|
+
import type {
|
|
29
|
+
PendingConfirmation,
|
|
30
|
+
ReplyFile,
|
|
31
|
+
} from "./core/domain/conversation.ts";
|
|
29
32
|
import { NotLinkedError, PluginError } from "./core/errors.ts";
|
|
30
33
|
import { EventBus } from "./core/events.ts";
|
|
31
34
|
import type { HoldCheck } from "./core/holds.ts";
|
|
@@ -48,6 +51,11 @@ import {
|
|
|
48
51
|
} from "./core/registry/contributions.ts";
|
|
49
52
|
import { resolveProviders } from "./core/registry/providers.ts";
|
|
50
53
|
import { ServiceRegistry } from "./core/registry/services.ts";
|
|
54
|
+
import {
|
|
55
|
+
attachReplyFile,
|
|
56
|
+
hasReplyFileScope,
|
|
57
|
+
withReplyFiles,
|
|
58
|
+
} from "./core/reply-files.ts";
|
|
51
59
|
import { ChannelQueue } from "./core/routing/channel-queue.ts";
|
|
52
60
|
import { ChannelRouter } from "./core/routing/channel-router.ts";
|
|
53
61
|
import {
|
|
@@ -203,6 +211,8 @@ export interface TestPluginResult {
|
|
|
203
211
|
tools: readonly string[];
|
|
204
212
|
tiers: ToolTierTable;
|
|
205
213
|
events: RecordedEvent[];
|
|
214
|
+
/** Files accepted by successful runTool calls, copied and recorded with their channel. */
|
|
215
|
+
files: { channel: ChannelKey; file: ReplyFile }[];
|
|
206
216
|
runTool(
|
|
207
217
|
name: string,
|
|
208
218
|
args: Record<string, unknown>,
|
|
@@ -233,7 +243,10 @@ function unlinked(part: keyof typeof NOT_LINKED): never {
|
|
|
233
243
|
const PROBED = new Set(["then", "toJSON", "asymmetricMatch"]);
|
|
234
244
|
|
|
235
245
|
/** A service the test gave only some members of: reading another names the option that adds it. */
|
|
236
|
-
function partialService
|
|
246
|
+
function partialService<T extends object>(
|
|
247
|
+
key: ServiceKey<unknown>,
|
|
248
|
+
given: T,
|
|
249
|
+
): T {
|
|
237
250
|
return new Proxy(given, {
|
|
238
251
|
get(target, member, receiver) {
|
|
239
252
|
if (member in target || typeof member === "symbol" || PROBED.has(member))
|
|
@@ -495,6 +508,7 @@ export async function testPlugin(
|
|
|
495
508
|
for (const surface of options.surfaces ?? []) await surface.start(deliver);
|
|
496
509
|
await runtime?.preflight?.();
|
|
497
510
|
let stopped = false;
|
|
511
|
+
const files: { channel: ChannelKey; file: ReplyFile }[] = [];
|
|
498
512
|
return {
|
|
499
513
|
contribution,
|
|
500
514
|
holds: linked.holds,
|
|
@@ -505,6 +519,7 @@ export async function testPlugin(
|
|
|
505
519
|
tools: registry.tools.map((tool) => tool.name),
|
|
506
520
|
tiers,
|
|
507
521
|
events,
|
|
522
|
+
files,
|
|
508
523
|
async runTool(name, args, runOptions) {
|
|
509
524
|
const tool = registry.tools.find((item) => item.name === name);
|
|
510
525
|
if (!tool) throw new PluginError(`tool ${name} is not registered`);
|
|
@@ -539,8 +554,23 @@ export async function testPlugin(
|
|
|
539
554
|
const execute = registered[0]?.execute;
|
|
540
555
|
if (!execute)
|
|
541
556
|
throw new PluginError(`tool ${name} did not register in the session`);
|
|
542
|
-
const result = await
|
|
543
|
-
|
|
557
|
+
const result = await withReplyFiles(
|
|
558
|
+
surfaces.of(channel)?.supportsFiles === true,
|
|
559
|
+
async () => {
|
|
560
|
+
const output = await execute("test-call", args);
|
|
561
|
+
return {
|
|
562
|
+
ok: true,
|
|
563
|
+
text: output.content.map((item) => item.text ?? "").join("\n"),
|
|
564
|
+
};
|
|
565
|
+
},
|
|
566
|
+
);
|
|
567
|
+
if (!result.ok) throw result.error;
|
|
568
|
+
for (const file of result.files ?? []) {
|
|
569
|
+
files.push({ channel, file });
|
|
570
|
+
// When runTool is used by a fake runtime, forward into the enclosing real turn.
|
|
571
|
+
if (hasReplyFileScope()) attachReplyFile(file);
|
|
572
|
+
}
|
|
573
|
+
return result.text;
|
|
544
574
|
},
|
|
545
575
|
async stop() {
|
|
546
576
|
if (stopped) return;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$schema": "https://biomejs.dev/schemas/2.5.
|
|
2
|
+
"$schema": "https://biomejs.dev/schemas/2.5.15/schema.json",
|
|
3
3
|
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
|
|
4
4
|
"formatter": { "indentStyle": "tab" },
|
|
5
5
|
"linter": { "rules": { "preset": "recommended" } },
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
AGENT_SERVER_PLUGIN,
|
|
7
|
+
AGENT_TEAM_SERVICE,
|
|
8
|
+
AGENTS,
|
|
9
|
+
type AgentTeam,
|
|
10
|
+
} from "pi-roundtable";
|
|
11
|
+
import { partial, servicePair, testPlugin } from "pi-roundtable/testing";
|
|
12
|
+
import { createReleaseNotice, noticeText } from "./release-notice.ts";
|
|
13
|
+
|
|
14
|
+
let dir: string;
|
|
15
|
+
let posted: string[];
|
|
16
|
+
|
|
17
|
+
beforeEach(async () => {
|
|
18
|
+
dir = await mkdtemp(join(tmpdir(), "release-notice-"));
|
|
19
|
+
posted = [];
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => rm(dir, { recursive: true, force: true }));
|
|
22
|
+
|
|
23
|
+
const releaseFile = () => join(dir, "release.json");
|
|
24
|
+
const dataDir = () => join(dir, "data");
|
|
25
|
+
const writeRelease = (sha: string, commits: string[]) =>
|
|
26
|
+
writeFile(releaseFile(), JSON.stringify({ sha, commits }));
|
|
27
|
+
|
|
28
|
+
/** The plugin over the temporary directory, posting into `posted`; `fail` makes the post throw. */
|
|
29
|
+
async function started(fail = false) {
|
|
30
|
+
const harness = await testPlugin(
|
|
31
|
+
createReleaseNotice({
|
|
32
|
+
releaseFile: releaseFile(),
|
|
33
|
+
dataDir: dataDir(),
|
|
34
|
+
announce: async (text) => {
|
|
35
|
+
if (fail) throw new Error("the channel is gone");
|
|
36
|
+
posted.push(text);
|
|
37
|
+
},
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
const events = harness.contribution.events;
|
|
41
|
+
return {
|
|
42
|
+
harness,
|
|
43
|
+
/** What the host delivers when the agent server's channels are up. */
|
|
44
|
+
ready: () =>
|
|
45
|
+
events?.serviceStarted?.({
|
|
46
|
+
plugin: AGENT_SERVER_PLUGIN,
|
|
47
|
+
service: AGENT_TEAM_SERVICE,
|
|
48
|
+
outcome: "ready",
|
|
49
|
+
}),
|
|
50
|
+
/** What the host delivers when the shutdown drain ends. */
|
|
51
|
+
shutdown: (left: string[]) => events?.shutdown?.(left),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
test("a start with no release.json announces nothing", async () => {
|
|
56
|
+
const run = await started();
|
|
57
|
+
await run.ready();
|
|
58
|
+
expect(posted).toEqual([]);
|
|
59
|
+
await run.harness.stop();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a new release is announced once, with its commits", async () => {
|
|
63
|
+
await writeRelease("abc1234", ["fix: b", "feat: a"]);
|
|
64
|
+
const first = await started();
|
|
65
|
+
await first.ready();
|
|
66
|
+
expect(posted).toEqual(["🔄 Updated to `abc1234`\n- fix: b\n- feat: a"]);
|
|
67
|
+
await first.harness.stop();
|
|
68
|
+
// The next start of the same release says nothing.
|
|
69
|
+
const second = await started();
|
|
70
|
+
await second.ready();
|
|
71
|
+
expect(posted).toHaveLength(1);
|
|
72
|
+
await second.harness.stop();
|
|
73
|
+
// A different release is announced again.
|
|
74
|
+
await writeRelease("def5678", ["feat: c"]);
|
|
75
|
+
const third = await started();
|
|
76
|
+
await third.ready();
|
|
77
|
+
expect(posted[1]).toBe("🔄 Updated to `def5678`\n- feat: c");
|
|
78
|
+
await third.harness.stop();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("only the agent server's ready event announces", async () => {
|
|
82
|
+
await writeRelease("abc1234", ["fix: b"]);
|
|
83
|
+
const harness = await testPlugin(
|
|
84
|
+
createReleaseNotice({
|
|
85
|
+
releaseFile: releaseFile(),
|
|
86
|
+
dataDir: dataDir(),
|
|
87
|
+
announce: async (text) => {
|
|
88
|
+
posted.push(text);
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
const serviceStarted = harness.contribution.events?.serviceStarted;
|
|
93
|
+
await serviceStarted?.({
|
|
94
|
+
plugin: AGENT_SERVER_PLUGIN,
|
|
95
|
+
service: AGENT_TEAM_SERVICE,
|
|
96
|
+
outcome: "failed",
|
|
97
|
+
});
|
|
98
|
+
await serviceStarted?.({
|
|
99
|
+
plugin: "other",
|
|
100
|
+
service: AGENT_TEAM_SERVICE,
|
|
101
|
+
outcome: "ready",
|
|
102
|
+
});
|
|
103
|
+
await serviceStarted?.({
|
|
104
|
+
plugin: AGENT_SERVER_PLUGIN,
|
|
105
|
+
service: "other",
|
|
106
|
+
outcome: "ready",
|
|
107
|
+
});
|
|
108
|
+
expect(posted).toEqual([]);
|
|
109
|
+
await harness.stop();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("work a shutdown cut short is reported once, after a restart of the same release", async () => {
|
|
113
|
+
await writeRelease("abc1234", ["fix: b"]);
|
|
114
|
+
const first = await started();
|
|
115
|
+
await first.ready();
|
|
116
|
+
await first.shutdown(["discord:111", "agentgroup:222.infra", "discord:111"]);
|
|
117
|
+
const second = await started();
|
|
118
|
+
await second.ready();
|
|
119
|
+
expect(posted[1]).toBe(
|
|
120
|
+
"🔄 Restarted (`abc1234`)\n-# Cut short by the restart, still running when the shutdown wait ended: <#111>, <#222>",
|
|
121
|
+
);
|
|
122
|
+
await second.harness.stop();
|
|
123
|
+
const third = await started();
|
|
124
|
+
await third.ready();
|
|
125
|
+
expect(posted).toHaveLength(2);
|
|
126
|
+
await third.harness.stop();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a shutdown that left nothing records nothing", async () => {
|
|
130
|
+
await writeRelease("abc1234", []);
|
|
131
|
+
const first = await started();
|
|
132
|
+
await first.ready();
|
|
133
|
+
await first.shutdown([]);
|
|
134
|
+
await first.harness.stop();
|
|
135
|
+
const second = await started();
|
|
136
|
+
await second.ready();
|
|
137
|
+
expect(posted).toHaveLength(1);
|
|
138
|
+
await second.harness.stop();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("a post that fails leaves the release unannounced, so the next start tries again", async () => {
|
|
142
|
+
await writeRelease("abc1234", ["fix: b"]);
|
|
143
|
+
const failing = await started(true);
|
|
144
|
+
await expect(failing.ready()).rejects.toThrow("the channel is gone");
|
|
145
|
+
await failing.harness.stop();
|
|
146
|
+
expect(await Bun.file(join(dataDir(), "announced-release")).exists()).toBe(
|
|
147
|
+
false,
|
|
148
|
+
);
|
|
149
|
+
const next = await started();
|
|
150
|
+
await next.ready();
|
|
151
|
+
expect(posted).toEqual(["🔄 Updated to `abc1234`\n- fix: b"]);
|
|
152
|
+
await next.harness.stop();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("a release.json that is not a valid description fails with its path and announces nothing", async () => {
|
|
156
|
+
for (const bad of [
|
|
157
|
+
"",
|
|
158
|
+
"{ not json",
|
|
159
|
+
"null",
|
|
160
|
+
"[]",
|
|
161
|
+
"{}",
|
|
162
|
+
'{"sha":"","commits":[]}',
|
|
163
|
+
'{"sha":"abc1234"}',
|
|
164
|
+
'{"sha":"abc1234","commits":"fix: b"}',
|
|
165
|
+
'{"sha":"abc1234","commits":["fix: b",null,{}]}',
|
|
166
|
+
]) {
|
|
167
|
+
await writeFile(releaseFile(), bad);
|
|
168
|
+
const run = await started();
|
|
169
|
+
await expect(run.ready()).rejects.toThrow(
|
|
170
|
+
`${releaseFile()} is not a valid release description`,
|
|
171
|
+
);
|
|
172
|
+
await run.harness.stop();
|
|
173
|
+
}
|
|
174
|
+
expect(posted).toEqual([]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("a recorded list that is not a list of strings fails with its path", async () => {
|
|
178
|
+
await writeRelease("abc1234", []);
|
|
179
|
+
await mkdir(dataDir(), { recursive: true });
|
|
180
|
+
await writeFile(join(dataDir(), "aborted-on-shutdown.json"), '["a", 1]');
|
|
181
|
+
const run = await started();
|
|
182
|
+
await expect(run.ready()).rejects.toThrow("is not a list of strings");
|
|
183
|
+
await run.harness.stop();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("a shutdown during the post is not lost, and one that ends before the next start adds to the unannounced", async () => {
|
|
187
|
+
await writeRelease("abc1234", ["fix: b"]);
|
|
188
|
+
let finish = () => {};
|
|
189
|
+
const harness = await testPlugin(
|
|
190
|
+
createReleaseNotice({
|
|
191
|
+
releaseFile: releaseFile(),
|
|
192
|
+
dataDir: dataDir(),
|
|
193
|
+
announce: (text) =>
|
|
194
|
+
new Promise<void>((resolve) => {
|
|
195
|
+
posted.push(text);
|
|
196
|
+
finish = resolve;
|
|
197
|
+
}),
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
const events = harness.contribution.events;
|
|
201
|
+
const announcing = events?.serviceStarted?.({
|
|
202
|
+
plugin: AGENT_SERVER_PLUGIN,
|
|
203
|
+
service: AGENT_TEAM_SERVICE,
|
|
204
|
+
outcome: "ready",
|
|
205
|
+
});
|
|
206
|
+
// The host shuts down while the notice is still being posted.
|
|
207
|
+
while (posted.length === 0) await Bun.sleep(1);
|
|
208
|
+
await events?.shutdown?.(["discord:111"]);
|
|
209
|
+
finish();
|
|
210
|
+
await announcing;
|
|
211
|
+
await events?.shutdown?.(["discord:333"]);
|
|
212
|
+
await harness.stop();
|
|
213
|
+
const next = await started();
|
|
214
|
+
await next.ready();
|
|
215
|
+
expect(posted[1]).toContain("<#111>, <#333>");
|
|
216
|
+
await next.harness.stop();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("without an announce option the notice goes to the agent team", async () => {
|
|
220
|
+
await writeRelease("abc1234", ["fix: b"]);
|
|
221
|
+
const harness = await testPlugin(
|
|
222
|
+
createReleaseNotice({ releaseFile: releaseFile(), dataDir: dataDir() }),
|
|
223
|
+
{
|
|
224
|
+
services: [
|
|
225
|
+
servicePair(AGENTS, {
|
|
226
|
+
team: partial<AgentTeam>({
|
|
227
|
+
announce: async (text) => {
|
|
228
|
+
posted.push(text);
|
|
229
|
+
},
|
|
230
|
+
}),
|
|
231
|
+
}),
|
|
232
|
+
],
|
|
233
|
+
},
|
|
234
|
+
);
|
|
235
|
+
await harness.contribution.events?.serviceStarted?.({
|
|
236
|
+
plugin: AGENT_SERVER_PLUGIN,
|
|
237
|
+
service: AGENT_TEAM_SERVICE,
|
|
238
|
+
outcome: "ready",
|
|
239
|
+
});
|
|
240
|
+
expect(posted).toEqual(["🔄 Updated to `abc1234`\n- fix: b"]);
|
|
241
|
+
await harness.stop();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("a channel key that is not Discord's is named as it is", () => {
|
|
245
|
+
expect(noticeText({ sha: "a", commits: [] }, false, ["slack:C1"])).toContain(
|
|
246
|
+
"slack:C1",
|
|
247
|
+
);
|
|
248
|
+
});
|