pi-roundtable 0.1.0 → 0.2.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/CHANGELOG.md +148 -0
- package/README.md +10 -6
- package/README.zh-TW.md +144 -0
- package/docs/plugins.md +1448 -66
- package/examples/echo-runtime.test.ts +122 -0
- package/examples/echo-runtime.ts +107 -0
- package/examples/events.test.ts +1 -0
- package/examples/events.ts +2 -2
- package/examples/fake-surface.test.ts +180 -0
- package/examples/fake-surface.ts +109 -0
- package/examples/interactions.test.ts +12 -3
- package/examples/interactions.ts +19 -19
- package/examples/shared-services.test.ts +74 -0
- package/examples/shared-services.ts +60 -0
- package/examples/study-room.test.ts +69 -0
- package/examples/study-room.ts +52 -0
- package/examples/support-desk.test.ts +34 -0
- package/examples/support-desk.ts +40 -0
- package/package.json +11 -3
- package/src/cli/checks/database.ts +11 -30
- package/src/cli/checks/images.ts +19 -0
- package/src/cli/doctor.ts +6 -0
- package/src/cli/main.ts +2 -1
- package/src/core/agents/agent-claim.ts +42 -16
- package/src/core/agents/agent-messages.ts +4 -4
- package/src/core/agents/agent-ports.ts +8 -21
- package/src/core/agents/agent-prompt.ts +3 -1
- package/src/core/agents/agent-store.ts +6 -4
- package/src/core/agents/agent-team-fixture.ts +389 -0
- package/src/core/agents/agent-team.ts +26 -25
- package/src/core/agents/agent-tools.ts +34 -15
- package/src/core/agents/avatar-studio.ts +40 -6
- package/src/core/agents/fallback-avatar.ts +85 -0
- package/src/core/agents/team-editing.ts +49 -10
- package/src/core/agents/team-keys.ts +22 -9
- package/src/core/agents/team-layout.ts +4 -3
- package/src/core/agents/team-lifecycle.ts +28 -2
- package/src/core/agents/team-options.ts +9 -5
- package/src/core/agents/team-status.ts +4 -4
- package/src/core/agents/team-text.ts +8 -6
- package/src/core/agents/team-turn-types.ts +4 -4
- package/src/core/agents/team-turns.ts +6 -5
- package/src/core/builtin/agent-server.ts +187 -121
- package/src/core/builtin/discord-admin.ts +41 -0
- package/src/core/builtin/discord.ts +65 -40
- package/src/core/builtin/modules.ts +67 -58
- package/src/core/builtin/session-tool.ts +24 -0
- package/src/core/builtin/skills.ts +72 -0
- package/src/core/builtin/stores.ts +55 -31
- package/src/core/config/config.ts +45 -4
- package/src/core/config/schema.ts +6 -0
- package/src/core/contract/channels.ts +54 -14
- package/src/core/contract/providers.ts +17 -0
- package/src/core/contract/runtime.ts +138 -0
- package/src/core/contract/services.ts +45 -0
- package/src/core/contract/surface.ts +88 -0
- package/src/core/db/migrations.ts +118 -3
- package/src/core/define-roundtable.ts +70 -46
- package/src/core/define.ts +2 -1
- package/src/core/discord/agent-commands.ts +39 -14
- package/src/core/discord/agent-panel.ts +66 -0
- package/src/core/discord/channel-executor.ts +6 -3
- package/src/core/discord/channel-operations.ts +14 -11
- package/src/core/discord/command-collection.ts +46 -0
- package/src/core/{registry/interactions.ts → discord/compose-commands.ts} +5 -5
- package/src/core/discord/connection.ts +35 -0
- package/src/core/discord/discord-surface.ts +32 -94
- package/src/core/discord/dispatch-threads.ts +2 -2
- package/src/core/discord/inbound-message.ts +99 -0
- package/src/core/discord/interaction-module.ts +57 -1
- package/src/core/discord/owner-cards.ts +4 -4
- package/src/core/discord/owner-command.ts +44 -11
- package/src/core/discord/owner-discord.ts +2 -2
- package/src/core/discord/schedule-commands.ts +21 -18
- package/src/core/discord/stop-button.ts +44 -1
- package/src/core/domain/attachment.ts +12 -8
- package/src/core/domain/conversation.ts +3 -4
- package/src/core/domain/owner-prompts.ts +5 -5
- package/src/core/domain/ports.ts +9 -53
- package/src/core/freeze.ts +9 -0
- package/src/core/host.ts +255 -82
- package/src/core/http/listeners.ts +55 -20
- package/src/core/i18n/agent-panel.ts +4 -0
- package/src/core/i18n/index.ts +12 -2
- package/src/core/i18n/owner.ts +7 -1
- package/src/core/i18n/schedules.ts +12 -6
- package/src/core/identity.ts +1 -1
- package/src/core/judging/effort-judge.ts +25 -3
- package/src/core/log.ts +21 -3
- package/src/core/models.ts +2 -2
- package/src/core/modules/background/background-turns.ts +6 -4
- package/src/core/modules/delegation/delegate.ts +3 -2
- package/src/core/modules/delegation/delegator.ts +15 -13
- package/src/core/modules/delegation/{sol-worker.ts → web-research-worker.ts} +6 -6
- package/src/core/modules/discord-admin/discord-admin.ts +6 -6
- package/src/core/modules/host-shell/shell-policy.ts +8 -3
- package/src/core/modules/memory/owner-memory-store.ts +27 -25
- package/src/core/modules/memory/owner-memory.ts +9 -13
- package/src/core/modules/schedules/recurrence.ts +5 -3
- package/src/core/modules/schedules/schedule-store.ts +11 -11
- package/src/core/modules/schedules/schedule-tools.ts +22 -20
- package/src/core/modules/schedules/scheduler.ts +7 -2
- package/src/core/modules/schedules/schedules.ts +9 -3
- package/src/core/modules/skills/skill-registry.ts +3 -2
- package/src/core/modules/skills/skill-store.ts +2 -15
- package/src/core/modules/skills/skill-tools.ts +10 -4
- package/src/core/ops/error-reporter.ts +1 -1
- package/src/core/plugin.ts +232 -28
- package/src/core/registry/contributions.ts +173 -14
- package/src/core/registry/providers.ts +35 -5
- package/src/core/registry/services.ts +229 -0
- package/src/core/routing/channel-queue.ts +5 -0
- package/src/core/routing/channel-router.ts +21 -7
- package/src/core/routing/conversation-turns.ts +139 -0
- package/src/core/routing/message-text.ts +9 -2
- package/src/core/routing/surface-port.ts +39 -0
- package/src/core/runtime/conversation-sessions.ts +4 -3
- package/src/core/runtime/extensions/confirmation-fixture.ts +11 -0
- package/src/core/runtime/extensions/confirmation-gate.ts +11 -9
- package/src/core/runtime/mcp.ts +1 -1
- package/src/core/runtime/pending-confirmation-store.ts +10 -12
- package/src/core/runtime/pi-agent-runtime.ts +9 -6
- package/src/core/runtime/prompt-slot.ts +8 -3
- package/src/core/runtime/runtime-types.ts +9 -35
- package/src/core/runtime/session-factory.ts +21 -5
- package/src/core/runtime/text-tools.ts +1 -3
- package/src/core/services.ts +238 -90
- package/src/core/sessions.ts +3 -2
- package/src/core/shared/{profile-tools.ts → active-tools.ts} +2 -2
- package/src/core/shared/delegate-tool.ts +4 -3
- package/src/core/shared/schedule-tools.ts +22 -13
- package/src/core/shared/session-messages.ts +1 -1
- package/src/core/speakers.ts +6 -38
- package/src/core/testing/database.ts +1 -7
- package/src/core/testing/eager-catalog.ts +30 -0
- package/src/core/testing/locale.ts +23 -5
- package/src/core/testing/modules.ts +105 -41
- package/src/core/testing/test-host.ts +279 -0
- package/src/core/testing/tool-set.ts +64 -0
- package/src/core/time.ts +6 -1
- package/src/core/tool-set.snapshot.json +288 -0
- package/src/core/tool-tiers.ts +17 -57
- package/src/discord/index.ts +64 -0
- package/src/index.ts +181 -10
- package/src/kit/channels.ts +14 -0
- package/src/kit/domain.ts +18 -0
- package/src/kit/holds.ts +4 -0
- package/src/kit/index.ts +129 -0
- package/src/kit/judging.ts +10 -0
- package/src/kit/memory.ts +3 -0
- package/src/kit/mirror.ts +19 -0
- package/src/kit/presentation.ts +7 -0
- package/src/kit/shell.ts +7 -0
- package/src/kit/skills.ts +8 -0
- package/src/kit/support.ts +21 -0
- package/src/kit/threads.ts +7 -0
- package/src/kit/tools.ts +14 -0
- package/src/kit/worker.ts +16 -0
- package/src/testing.ts +394 -34
- package/examples/guide.test.ts +0 -52
- package/src/cli/add-plugin.test.ts +0 -107
- package/src/cli/checks/basic.test.ts +0 -276
- package/src/cli/checks/database.test.ts +0 -117
- package/src/cli/checks/discord.test.ts +0 -200
- package/src/cli/cli.test.ts +0 -122
- package/src/cli/config-edit.test.ts +0 -130
- package/src/cli/doctor.test.ts +0 -184
- package/src/cli/init.test.ts +0 -127
- package/src/cli/size.test.ts +0 -9
- package/src/cli/templates.test.ts +0 -129
- package/src/cli/testing/fixtures.ts +0 -106
- package/src/core/agents/agent-claim.test.ts +0 -142
- package/src/core/agents/agent-dashboard.test.ts +0 -120
- package/src/core/agents/agent-guild.test.ts +0 -124
- package/src/core/agents/agent-store.test.ts +0 -205
- package/src/core/agents/avatar-studio.test.ts +0 -88
- package/src/core/agents/group-round.test.ts +0 -143
- package/src/core/agents/owner-identity.test.ts +0 -172
- package/src/core/agents/team-turns.test.ts +0 -278
- package/src/core/attachments/attachments.test.ts +0 -85
- package/src/core/boundary.test.ts +0 -45
- package/src/core/builtin/modules.test.ts +0 -127
- package/src/core/config/config.test.ts +0 -135
- package/src/core/contract/discord.ts +0 -33
- package/src/core/db/migrations.test.ts +0 -237
- package/src/core/define-roundtable.test.ts +0 -134
- package/src/core/define.test.ts +0 -144
- package/src/core/discord/agent-commands.test.ts +0 -58
- package/src/core/discord/agent-discord.test.ts +0 -70
- package/src/core/discord/dispatch-thread-host.test.ts +0 -114
- package/src/core/discord/dispatch-threads.test.ts +0 -94
- package/src/core/discord/owner-cards.test.ts +0 -435
- package/src/core/discord/owner-discord.test.ts +0 -325
- package/src/core/domain/expression.ts +0 -21
- package/src/core/domain/profile.ts +0 -32
- package/src/core/drain.test.ts +0 -46
- package/src/core/events.test.ts +0 -81
- package/src/core/holds.test.ts +0 -54
- package/src/core/host.test.ts +0 -536
- package/src/core/http/listeners.test.ts +0 -164
- package/src/core/i18n/i18n.test.ts +0 -139
- package/src/core/identity.test.ts +0 -18
- package/src/core/judging/effort-judge.test.ts +0 -112
- package/src/core/judging/model-judge.test.ts +0 -126
- package/src/core/modules/delegation/delegator.test.ts +0 -170
- package/src/core/modules/memory/owner-memory-store.test.ts +0 -244
- package/src/core/modules/schedules/schedule.test.ts +0 -358
- package/src/core/modules/skills/skill-kind.test.ts +0 -55
- package/src/core/modules/skills/skill-registry.test.ts +0 -376
- package/src/core/ops/error-reporter.test.ts +0 -283
- package/src/core/presentation/card-cadence.ts +0 -41
- package/src/core/presentation/presentation.test.ts +0 -166
- package/src/core/public-entry.test.ts +0 -59
- package/src/core/registry/contributions.test.ts +0 -215
- package/src/core/registry/interactions.test.ts +0 -87
- package/src/core/registry/providers.test.ts +0 -103
- package/src/core/routing/channel-queue.test.ts +0 -31
- package/src/core/routing/channel-router.test.ts +0 -326
- package/src/core/routing/conversation-kind.ts +0 -14
- package/src/core/routing/settle-turn.test.ts +0 -21
- package/src/core/runtime/compaction-tiers.test.ts +0 -227
- package/src/core/runtime/extensions/ask-user.test.ts +0 -114
- package/src/core/runtime/extensions/self-compact-guard.test.ts +0 -50
- package/src/core/runtime/pending-confirmation-store.test.ts +0 -52
- package/src/core/runtime/session-archive.test.ts +0 -20
- package/src/core/runtime/steerable-run.test.ts +0 -264
- package/src/core/runtime/text-tools.test.ts +0 -110
- package/src/core/runtime/turn-answer.test.ts +0 -72
- package/src/core/runtime/worker-task.test.ts +0 -82
- package/src/core/services.test.ts +0 -28
- package/src/core/sessions.test.ts +0 -85
- package/src/core/shared/unix-server.ts +0 -18
- package/src/core/size.test.ts +0 -9
- package/src/core/speakers.test.ts +0 -78
- package/src/core/testing/file-size.ts +0 -34
- package/src/core/time.test.ts +0 -166
- package/src/core/tool-tiers.test.ts +0 -117
- package/src/entries.test.ts +0 -112
- package/src/testing.test.ts +0 -156
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { ChannelType, type Client } from "discord.js";
|
|
3
|
-
import { messages } from "../i18n/index.ts";
|
|
4
|
-
import { DiscordThreadHost } from "./dispatch-thread-host.ts";
|
|
5
|
-
|
|
6
|
-
class FakeThread {
|
|
7
|
-
readonly sent: string[] = [];
|
|
8
|
-
readonly edits: unknown[] = [];
|
|
9
|
-
archived = false;
|
|
10
|
-
failEdit = false;
|
|
11
|
-
constructor(readonly id: string) {}
|
|
12
|
-
isThread() {
|
|
13
|
-
return true;
|
|
14
|
-
}
|
|
15
|
-
async send({ content }: { content: string }) {
|
|
16
|
-
this.sent.push(content);
|
|
17
|
-
}
|
|
18
|
-
async edit(change: unknown) {
|
|
19
|
-
if (this.failEdit) throw new Error("Missing Permissions");
|
|
20
|
-
this.edits.push(change);
|
|
21
|
-
}
|
|
22
|
-
async setArchived(archived: boolean) {
|
|
23
|
-
this.archived = archived;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
class FakeStartMessage {
|
|
28
|
-
deleted = false;
|
|
29
|
-
thread: { name: string; autoArchiveDuration: number } | undefined;
|
|
30
|
-
constructor(
|
|
31
|
-
public content: string,
|
|
32
|
-
readonly failThread: boolean,
|
|
33
|
-
) {}
|
|
34
|
-
async startThread(options: { name: string; autoArchiveDuration: number }) {
|
|
35
|
-
if (this.failThread) throw new Error("Missing Permissions");
|
|
36
|
-
this.thread = options;
|
|
37
|
-
return { id: "700" };
|
|
38
|
-
}
|
|
39
|
-
async edit({ content }: { content: string }) {
|
|
40
|
-
this.content = content;
|
|
41
|
-
}
|
|
42
|
-
async delete() {
|
|
43
|
-
this.deleted = true;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
class FakeParent {
|
|
48
|
-
readonly messages: FakeStartMessage[] = [];
|
|
49
|
-
failThread = false;
|
|
50
|
-
constructor(readonly type: ChannelType) {}
|
|
51
|
-
isThread() {
|
|
52
|
-
return false;
|
|
53
|
-
}
|
|
54
|
-
isSendable() {
|
|
55
|
-
return true;
|
|
56
|
-
}
|
|
57
|
-
async send({ content }: { content: string }) {
|
|
58
|
-
const message = new FakeStartMessage(content, this.failThread);
|
|
59
|
-
this.messages.push(message);
|
|
60
|
-
return message;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function setup() {
|
|
65
|
-
const channels = new Map<string, unknown>([
|
|
66
|
-
["10", new FakeParent(ChannelType.GuildText)],
|
|
67
|
-
["11", new FakeParent(ChannelType.DM)],
|
|
68
|
-
["12", new FakeParent(ChannelType.PublicThread)],
|
|
69
|
-
["700", new FakeThread("700")],
|
|
70
|
-
]);
|
|
71
|
-
const client = {
|
|
72
|
-
channels: { fetch: async (id: string) => channels.get(id) ?? null },
|
|
73
|
-
} as unknown as Client;
|
|
74
|
-
return {
|
|
75
|
-
host: new DiscordThreadHost(client),
|
|
76
|
-
parent: (id: string) => channels.get(id) as FakeParent,
|
|
77
|
-
thread: channels.get("700") as FakeThread,
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const line = (thread?: string) => messages().threadStarted("t", thread);
|
|
82
|
-
|
|
83
|
-
describe("DiscordThreadHost", () => {
|
|
84
|
-
test("starts the thread from the start line, then links it there", async () => {
|
|
85
|
-
const { host, parent } = setup();
|
|
86
|
-
expect(await host.open("10", "repo #1", line)).toBe("700");
|
|
87
|
-
const [start] = parent("10").messages;
|
|
88
|
-
expect(start?.thread?.name).toBe("repo #1");
|
|
89
|
-
expect(start?.content).toBe(messages().threadStarted("t", "<#700>"));
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
test("DMs and threads cannot host one; a refused thread removes its start line", async () => {
|
|
93
|
-
const { host, parent } = setup();
|
|
94
|
-
expect(await host.open("11", "t", line)).toBeUndefined();
|
|
95
|
-
expect(await host.open("12", "t", line)).toBeUndefined();
|
|
96
|
-
expect(await host.open("99", "t", line).catch(() => "threw")).toBe(
|
|
97
|
-
undefined,
|
|
98
|
-
);
|
|
99
|
-
parent("10").failThread = true;
|
|
100
|
-
expect(await host.open("10", "t", line).catch(() => "threw")).toBe("threw");
|
|
101
|
-
expect(parent("10").messages[0]?.deleted).toBe(true);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
test("posts in the thread, then archives and locks it, or only archives without the permission", async () => {
|
|
105
|
-
const { host, thread } = setup();
|
|
106
|
-
await host.post("700", "report");
|
|
107
|
-
expect(thread.sent).toEqual(["report"]);
|
|
108
|
-
await host.close("700");
|
|
109
|
-
expect(thread.edits).toEqual([{ archived: true, locked: true }]);
|
|
110
|
-
thread.failEdit = true;
|
|
111
|
-
await host.close("700");
|
|
112
|
-
expect(thread.archived).toBe(true);
|
|
113
|
-
});
|
|
114
|
-
});
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
|
-
import { messages } from "../i18n/index.ts";
|
|
4
|
-
import { silentLogger } from "../log.ts";
|
|
5
|
-
import { FakeThreadHost, fakeThreads } from "../testing/thread-host.ts";
|
|
6
|
-
import { DispatchThreads, threadName } from "./dispatch-threads.ts";
|
|
7
|
-
|
|
8
|
-
describe("DispatchThreads", () => {
|
|
9
|
-
test("opens a thread under the origin with a start line there and the intro inside", async () => {
|
|
10
|
-
const { host, threads } = fakeThreads();
|
|
11
|
-
const thread = await threads.open(
|
|
12
|
-
"discord:10",
|
|
13
|
-
"research",
|
|
14
|
-
"📋 Task: find it",
|
|
15
|
-
);
|
|
16
|
-
expect(host.opened).toEqual([
|
|
17
|
-
{ parentId: "10", name: "research", id: "900" },
|
|
18
|
-
]);
|
|
19
|
-
expect(host.lines.get("10")).toBe(
|
|
20
|
-
messages().threadStarted("research", "<#900>"),
|
|
21
|
-
);
|
|
22
|
-
expect(thread?.mention).toBe("<#900>");
|
|
23
|
-
expect(host.textsIn("900")).toEqual(["📋 Task: find it"]);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
test("close posts the report, archives once, and forgets the thread", async () => {
|
|
27
|
-
const { host, threads, ledgerPath } = fakeThreads();
|
|
28
|
-
const thread = await threads.open("discord:10", "t");
|
|
29
|
-
expect(Object.keys(JSON.parse(readFileSync(ledgerPath, "utf8")))).toEqual([
|
|
30
|
-
"900",
|
|
31
|
-
]);
|
|
32
|
-
await thread?.close("✅ Done");
|
|
33
|
-
await thread?.close("again");
|
|
34
|
-
expect(host.textsIn("900")).toEqual(["✅ Done"]);
|
|
35
|
-
expect(host.closed).toEqual(["900"]);
|
|
36
|
-
expect(JSON.parse(readFileSync(ledgerPath, "utf8"))).toEqual({});
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test("no thread outside Discord, in excluded channels, where the host cannot, or when creation fails", async () => {
|
|
40
|
-
const host = new FakeThreadHost();
|
|
41
|
-
host.hostless.add("20");
|
|
42
|
-
const { threads, ledgerPath } = fakeThreads(host);
|
|
43
|
-
const party = new DispatchThreads({
|
|
44
|
-
host,
|
|
45
|
-
ledgerPath,
|
|
46
|
-
excluded: (channel) => channel === "discord:30",
|
|
47
|
-
logger: silentLogger(),
|
|
48
|
-
});
|
|
49
|
-
expect(await threads.open(undefined, "t")).toBeUndefined();
|
|
50
|
-
expect(await threads.open("mcp:abc", "t")).toBeUndefined();
|
|
51
|
-
expect(await threads.open("discord:20", "t")).toBeUndefined();
|
|
52
|
-
expect(await party.open("discord:30", "t")).toBeUndefined();
|
|
53
|
-
host.failOpen = true;
|
|
54
|
-
expect(await threads.open("discord:40", "t")).toBeUndefined();
|
|
55
|
-
expect(host.opened).toEqual([]);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
test("a failed post or archive is logged, not thrown", async () => {
|
|
59
|
-
const host = new FakeThreadHost();
|
|
60
|
-
host.post = async () => {
|
|
61
|
-
throw new Error("gone");
|
|
62
|
-
};
|
|
63
|
-
host.close = async () => {
|
|
64
|
-
throw new Error("gone");
|
|
65
|
-
};
|
|
66
|
-
const { threads } = fakeThreads(host);
|
|
67
|
-
const thread = await threads.open("discord:10", "t", "intro");
|
|
68
|
-
await thread?.post("x");
|
|
69
|
-
await thread?.close("report");
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test("a start after a restart archives the threads left open, with a notice", async () => {
|
|
73
|
-
const { host, threads, ledgerPath } = fakeThreads();
|
|
74
|
-
await threads.open("discord:10", "a");
|
|
75
|
-
const done = await threads.open("discord:11", "b");
|
|
76
|
-
await done?.close();
|
|
77
|
-
const restarted = new DispatchThreads({
|
|
78
|
-
host,
|
|
79
|
-
ledgerPath,
|
|
80
|
-
logger: silentLogger(),
|
|
81
|
-
});
|
|
82
|
-
await restarted.sweep();
|
|
83
|
-
expect(host.closed).toEqual(["901", "900"]);
|
|
84
|
-
expect(host.textsIn("900")).toEqual([messages().restartNotice]);
|
|
85
|
-
expect(JSON.parse(readFileSync(ledgerPath, "utf8"))).toEqual({});
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test("thread names are one line within Discord's 100 characters", () => {
|
|
89
|
-
expect(threadName(" a\nb ")).toBe("a b");
|
|
90
|
-
const long = threadName("x".repeat(150));
|
|
91
|
-
expect(long.length).toBe(100);
|
|
92
|
-
expect(long.endsWith("…")).toBe(true);
|
|
93
|
-
});
|
|
94
|
-
});
|
|
@@ -1,435 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import type { Interaction } from "discord.js";
|
|
3
|
-
import type { OwnerQuestion } from "../domain/owner-prompts.ts";
|
|
4
|
-
import { messages } from "../i18n/index.ts";
|
|
5
|
-
import { silentLogger } from "../log.ts";
|
|
6
|
-
import { speakerPolicy } from "../speakers.ts";
|
|
7
|
-
import {
|
|
8
|
-
CARD_PREFIX,
|
|
9
|
-
type CardPayload,
|
|
10
|
-
OwnerCards,
|
|
11
|
-
type OwnerCardsOptions,
|
|
12
|
-
} from "./owner-cards.ts";
|
|
13
|
-
|
|
14
|
-
const OWNER = "100000000000000001";
|
|
15
|
-
|
|
16
|
-
/** The card's JSON, where its text, custom ids, and disabled flags can be read. */
|
|
17
|
-
const json = (payload: CardPayload | undefined) =>
|
|
18
|
-
JSON.stringify(payload?.components.map((c) => c.toJSON()));
|
|
19
|
-
|
|
20
|
-
/** A channel, or a thread, that records the cards sent to it and every edit of them. */
|
|
21
|
-
function fakeChannel(thread = false) {
|
|
22
|
-
const sent: CardPayload[] = [];
|
|
23
|
-
const edits: CardPayload[] = [];
|
|
24
|
-
return {
|
|
25
|
-
sent,
|
|
26
|
-
edits,
|
|
27
|
-
/** The id of the latest card sent. */
|
|
28
|
-
cardId: () =>
|
|
29
|
-
json(sent.at(-1)).match(new RegExp(`${CARD_PREFIX}([0-9a-f-]+):`))?.[1] ??
|
|
30
|
-
"",
|
|
31
|
-
channel: async () => ({
|
|
32
|
-
thread,
|
|
33
|
-
send: async (payload: CardPayload) => {
|
|
34
|
-
sent.push(payload);
|
|
35
|
-
return {
|
|
36
|
-
edit: async (change: CardPayload) => {
|
|
37
|
-
edits.push(change);
|
|
38
|
-
},
|
|
39
|
-
};
|
|
40
|
-
},
|
|
41
|
-
}),
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
type Kind = "button" | "select" | "modal";
|
|
46
|
-
|
|
47
|
-
/** A press of a card's control, recording how it was answered. */
|
|
48
|
-
function press(
|
|
49
|
-
kind: Kind,
|
|
50
|
-
customId: string,
|
|
51
|
-
options: {
|
|
52
|
-
user?: string;
|
|
53
|
-
roles?: string[];
|
|
54
|
-
values?: string[];
|
|
55
|
-
text?: string;
|
|
56
|
-
} = {},
|
|
57
|
-
) {
|
|
58
|
-
const replies: string[] = [];
|
|
59
|
-
const updates: CardPayload[] = [];
|
|
60
|
-
const modals: unknown[] = [];
|
|
61
|
-
const interaction = {
|
|
62
|
-
isButton: () => kind === "button",
|
|
63
|
-
isStringSelectMenu: () => kind === "select",
|
|
64
|
-
isModalSubmit: () => kind === "modal",
|
|
65
|
-
isFromMessage: () => true,
|
|
66
|
-
customId,
|
|
67
|
-
user: { id: options.user ?? OWNER },
|
|
68
|
-
member: {
|
|
69
|
-
roles: { cache: new Map((options.roles ?? []).map((r) => [r, r])) },
|
|
70
|
-
},
|
|
71
|
-
values: options.values ?? [],
|
|
72
|
-
fields: { getTextInputValue: () => options.text ?? "" },
|
|
73
|
-
reply: async (answer: { content: string }) => {
|
|
74
|
-
replies.push(answer.content);
|
|
75
|
-
},
|
|
76
|
-
update: async (payload: CardPayload) => {
|
|
77
|
-
updates.push(payload);
|
|
78
|
-
},
|
|
79
|
-
showModal: async (modal: unknown) => {
|
|
80
|
-
modals.push(modal);
|
|
81
|
-
},
|
|
82
|
-
deferUpdate: async () => undefined,
|
|
83
|
-
} as unknown as Interaction;
|
|
84
|
-
return { interaction, replies, updates, modals };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function setup(options: Partial<OwnerCardsOptions> = {}) {
|
|
88
|
-
const fake = fakeChannel();
|
|
89
|
-
const cards = new OwnerCards({
|
|
90
|
-
ownerId: OWNER,
|
|
91
|
-
channel: fake.channel,
|
|
92
|
-
logger: silentLogger(),
|
|
93
|
-
...options,
|
|
94
|
-
});
|
|
95
|
-
const prompts = cards.prompts("discord:555");
|
|
96
|
-
if (!prompts) throw new Error("no prompts for a Discord channel");
|
|
97
|
-
return { fake, cards, prompts };
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const tick = () => Bun.sleep(1);
|
|
101
|
-
|
|
102
|
-
describe("OwnerCards", () => {
|
|
103
|
-
test("a conversation outside Discord has no cards", () => {
|
|
104
|
-
const { cards } = setup();
|
|
105
|
-
expect(cards.prompts("mcp:agent")).toBeUndefined();
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
test("a dispatch thread's cards are posted in the thread", async () => {
|
|
109
|
-
const asked: string[] = [];
|
|
110
|
-
const fake = fakeChannel();
|
|
111
|
-
const cards = new OwnerCards({
|
|
112
|
-
ownerId: OWNER,
|
|
113
|
-
channel: async (channelId) => {
|
|
114
|
-
asked.push(channelId);
|
|
115
|
-
return fake.channel();
|
|
116
|
-
},
|
|
117
|
-
logger: silentLogger(),
|
|
118
|
-
timeoutMs: 5,
|
|
119
|
-
});
|
|
120
|
-
await cards.prompts("discord:900")?.confirm("t", "m");
|
|
121
|
-
expect(asked).toEqual(["900"]);
|
|
122
|
-
expect(fake.sent).toHaveLength(1);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
test("the owner approves; the card then shows the outcome with its buttons disabled", async () => {
|
|
126
|
-
const { fake, cards, prompts } = setup();
|
|
127
|
-
const answer = prompts.confirm(
|
|
128
|
-
"The assistant wants to run this action",
|
|
129
|
-
"**send mail**",
|
|
130
|
-
);
|
|
131
|
-
await tick();
|
|
132
|
-
expect(json(fake.sent[0])).toContain("send mail");
|
|
133
|
-
expect(json(fake.sent[0])).toContain(messages().cardRunLabel);
|
|
134
|
-
const yes = press("button", `${CARD_PREFIX}${fake.cardId()}:yes`);
|
|
135
|
-
expect(await cards.handle(yes.interaction)).toBe(true);
|
|
136
|
-
expect(await answer).toBe("approved");
|
|
137
|
-
expect(json(yes.updates[0])).toContain(messages().cardApproved);
|
|
138
|
-
expect(json(yes.updates[0])).toContain('"disabled":true');
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
test("a card in a channel mentions nobody", async () => {
|
|
142
|
-
const { fake, prompts } = setup({ timeoutMs: 5 });
|
|
143
|
-
await prompts.confirm(
|
|
144
|
-
"The assistant wants to run this action",
|
|
145
|
-
"**send mail**",
|
|
146
|
-
);
|
|
147
|
-
expect(fake.sent[0]?.allowedMentions).toEqual({ parse: [] });
|
|
148
|
-
expect(json(fake.sent[0])).not.toContain(`<@${OWNER}>`);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
test("a card in a thread mentions the owner alone, above the unchanged card", async () => {
|
|
152
|
-
const inChannel = fakeChannel();
|
|
153
|
-
const inThread = fakeChannel(true);
|
|
154
|
-
const cards = (fake: ReturnType<typeof fakeChannel>) =>
|
|
155
|
-
new OwnerCards({
|
|
156
|
-
ownerId: OWNER,
|
|
157
|
-
channel: fake.channel,
|
|
158
|
-
logger: silentLogger(),
|
|
159
|
-
});
|
|
160
|
-
const threadCards = cards(inThread);
|
|
161
|
-
void cards(inChannel).prompts("discord:555")?.confirm("t", "**m**");
|
|
162
|
-
const answer = threadCards.prompts("discord:900")?.confirm("t", "**m**");
|
|
163
|
-
await tick();
|
|
164
|
-
const card = inThread.sent[0];
|
|
165
|
-
expect(card?.allowedMentions).toEqual({ users: [OWNER] });
|
|
166
|
-
const [mention, ...panel] = card?.components ?? [];
|
|
167
|
-
expect(mention?.toJSON()).toMatchObject({ content: `<@${OWNER}>` });
|
|
168
|
-
// Past the mention the card is the one a channel gets.
|
|
169
|
-
expect(JSON.stringify(panel.map((c) => c.toJSON()))).toBe(
|
|
170
|
-
json(inChannel.sent[0]).replaceAll(inChannel.cardId(), inThread.cardId()),
|
|
171
|
-
);
|
|
172
|
-
const yes = press("button", `${CARD_PREFIX}${inThread.cardId()}:yes`);
|
|
173
|
-
await threadCards.handle(yes.interaction);
|
|
174
|
-
expect(await answer).toBe("approved");
|
|
175
|
-
// The closed card keeps its mention line.
|
|
176
|
-
expect(json(yes.updates[0])).toContain(`<@${OWNER}>`);
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
test("only the owner may answer; anyone else is refused and the card stays open", async () => {
|
|
180
|
-
const { fake, cards, prompts } = setup();
|
|
181
|
-
const answer = prompts.confirm("t", "m");
|
|
182
|
-
await tick();
|
|
183
|
-
const id = fake.cardId();
|
|
184
|
-
const stranger = press("button", `${CARD_PREFIX}${id}:yes`, { user: "1" });
|
|
185
|
-
await cards.handle(stranger.interaction);
|
|
186
|
-
expect(stranger.replies).toEqual([messages().cardOwnerOnly]);
|
|
187
|
-
expect(stranger.updates).toHaveLength(0);
|
|
188
|
-
const no = press("button", `${CARD_PREFIX}${id}:no`);
|
|
189
|
-
await cards.handle(no.interaction);
|
|
190
|
-
expect(await answer).toBe("declined");
|
|
191
|
-
expect(json(no.updates[0])).toContain(messages().cardDeclined);
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
test("an unanswered card expires and is edited to say so", async () => {
|
|
195
|
-
const { fake, cards, prompts } = setup({ timeoutMs: 5 });
|
|
196
|
-
expect(await prompts.confirm("t", "m")).toBe("expired");
|
|
197
|
-
await tick();
|
|
198
|
-
expect(json(fake.edits[0])).toContain(messages().cardExpired);
|
|
199
|
-
expect(json(fake.edits[0])).toContain('"disabled":true');
|
|
200
|
-
// A late press finds the card gone.
|
|
201
|
-
const late = press("button", `${CARD_PREFIX}${fake.cardId()}:yes`);
|
|
202
|
-
await cards.handle(late.interaction);
|
|
203
|
-
expect(late.replies[0]).toContain(messages().cardInactive);
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
test("a stopped turn cancels its card", async () => {
|
|
207
|
-
const { fake, prompts } = setup();
|
|
208
|
-
const stop = new AbortController();
|
|
209
|
-
const answer = prompts.confirm("t", "m", stop.signal);
|
|
210
|
-
await tick();
|
|
211
|
-
stop.abort();
|
|
212
|
-
expect(await answer).toBe("cancelled");
|
|
213
|
-
await tick();
|
|
214
|
-
expect(json(fake.edits[0])).toContain(messages().cardStopped);
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
test("a card that cannot be posted counts as expired", async () => {
|
|
218
|
-
const { prompts } = setup({
|
|
219
|
-
channel: async () => {
|
|
220
|
-
throw new Error("missing access");
|
|
221
|
-
},
|
|
222
|
-
});
|
|
223
|
-
expect(await prompts.confirm("t", "m")).toBe("expired");
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
test("a press of a card from before a restart says it no longer works", async () => {
|
|
227
|
-
const { cards } = setup();
|
|
228
|
-
const old = press("button", `${CARD_PREFIX}gone:yes`);
|
|
229
|
-
expect(await cards.handle(old.interaction)).toBe(true);
|
|
230
|
-
expect(old.replies[0]).toContain(messages().cardInactive);
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
test("other components are left to other modules", async () => {
|
|
234
|
-
const { cards } = setup();
|
|
235
|
-
expect(
|
|
236
|
-
await cards.handle(press("button", "roundtable:stop").interaction),
|
|
237
|
-
).toBe(false);
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
describe("questions", () => {
|
|
241
|
-
const question = (q: Partial<OwnerQuestion>): OwnerQuestion => ({
|
|
242
|
-
question: "Which day?",
|
|
243
|
-
options: [
|
|
244
|
-
{ label: "Saturday", description: "morning" },
|
|
245
|
-
{ label: "Sunday" },
|
|
246
|
-
{ label: "Monday" },
|
|
247
|
-
],
|
|
248
|
-
multi: false,
|
|
249
|
-
allowOther: false,
|
|
250
|
-
...q,
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
test("a single choice", async () => {
|
|
254
|
-
const { fake, cards, prompts } = setup();
|
|
255
|
-
const answer = prompts.ask(
|
|
256
|
-
"The assistant has a question for you",
|
|
257
|
-
question({}),
|
|
258
|
-
);
|
|
259
|
-
await tick();
|
|
260
|
-
const card = json(fake.sent[0]);
|
|
261
|
-
expect(card).toContain("Which day?");
|
|
262
|
-
expect(card).toContain('"max_values":1');
|
|
263
|
-
expect(card).not.toContain(messages().cardOtherLabel);
|
|
264
|
-
const pick = press("select", `${CARD_PREFIX}${fake.cardId()}:pick`, {
|
|
265
|
-
values: ["1"],
|
|
266
|
-
});
|
|
267
|
-
await cards.handle(pick.interaction);
|
|
268
|
-
expect(await answer).toEqual({ choices: ["Sunday"] });
|
|
269
|
-
expect(json(pick.updates[0])).toContain(
|
|
270
|
-
messages().cardAnswered(["Sunday"]),
|
|
271
|
-
);
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
test("several choices, in the order they were offered", async () => {
|
|
275
|
-
const { fake, cards, prompts } = setup();
|
|
276
|
-
const answer = prompts.ask("t", question({ multi: true }));
|
|
277
|
-
await tick();
|
|
278
|
-
expect(json(fake.sent[0])).toContain('"max_values":3');
|
|
279
|
-
await cards.handle(
|
|
280
|
-
press("select", `${CARD_PREFIX}${fake.cardId()}:pick`, {
|
|
281
|
-
values: ["2", "0"],
|
|
282
|
-
}).interaction,
|
|
283
|
-
);
|
|
284
|
-
expect(await answer).toEqual({ choices: ["Saturday", "Monday"] });
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
test("the other option opens a form, and its text comes back with the other choices", async () => {
|
|
288
|
-
const { fake, cards, prompts } = setup();
|
|
289
|
-
const answer = prompts.ask(
|
|
290
|
-
"t",
|
|
291
|
-
question({ multi: true, allowOther: true }),
|
|
292
|
-
);
|
|
293
|
-
await tick();
|
|
294
|
-
const id = fake.cardId();
|
|
295
|
-
expect(json(fake.sent[0])).toContain(messages().cardOtherLabel);
|
|
296
|
-
const pick = press("select", `${CARD_PREFIX}${id}:pick`, {
|
|
297
|
-
values: ["0", "other"],
|
|
298
|
-
});
|
|
299
|
-
await cards.handle(pick.interaction);
|
|
300
|
-
expect(pick.modals).toHaveLength(1);
|
|
301
|
-
expect(pick.updates).toHaveLength(0);
|
|
302
|
-
const submit = press("modal", `${CARD_PREFIX}${id}:text`, {
|
|
303
|
-
text: "Wednesday evening",
|
|
304
|
-
});
|
|
305
|
-
await cards.handle(submit.interaction);
|
|
306
|
-
expect(await answer).toEqual({
|
|
307
|
-
choices: ["Saturday"],
|
|
308
|
-
text: "Wednesday evening",
|
|
309
|
-
});
|
|
310
|
-
expect(json(submit.updates[0])).toContain("Wednesday evening");
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
test("without options, a button opens the answer form", async () => {
|
|
314
|
-
const { fake, cards, prompts } = setup();
|
|
315
|
-
const answer = prompts.ask("t", question({ options: [] }));
|
|
316
|
-
await tick();
|
|
317
|
-
const id = fake.cardId();
|
|
318
|
-
const write = press("button", `${CARD_PREFIX}${id}:write`);
|
|
319
|
-
await cards.handle(write.interaction);
|
|
320
|
-
expect(write.modals).toHaveLength(1);
|
|
321
|
-
await cards.handle(
|
|
322
|
-
press("modal", `${CARD_PREFIX}${id}:text`, { text: "anything" })
|
|
323
|
-
.interaction,
|
|
324
|
-
);
|
|
325
|
-
expect(await answer).toEqual({ choices: [], text: "anything" });
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
test("an unanswered question has no answer", async () => {
|
|
329
|
-
const { prompts } = setup({ timeoutMs: 5 });
|
|
330
|
-
expect(await prompts.ask("t", question({}))).toBeUndefined();
|
|
331
|
-
});
|
|
332
|
-
});
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
describe("cards for lower tiers", () => {
|
|
336
|
-
const ADMIN = "100000000000000002";
|
|
337
|
-
const MEMBER = "100000000000000003";
|
|
338
|
-
const ROLE = "100000000000000004";
|
|
339
|
-
const speakers = speakerPolicy({
|
|
340
|
-
owners: [OWNER],
|
|
341
|
-
admins: { users: [ADMIN] },
|
|
342
|
-
members: { roles: [ROLE] },
|
|
343
|
-
});
|
|
344
|
-
const askAdminApproval = () => {
|
|
345
|
-
const fake = fakeChannel();
|
|
346
|
-
const cards = new OwnerCards({
|
|
347
|
-
ownerId: OWNER,
|
|
348
|
-
speakers,
|
|
349
|
-
channel: fake.channel,
|
|
350
|
-
logger: silentLogger(),
|
|
351
|
-
});
|
|
352
|
-
const speaker = { id: ADMIN, name: "Ada", tier: "admin" } as const;
|
|
353
|
-
const answer = cards
|
|
354
|
-
.prompts("discord:555", speaker)
|
|
355
|
-
?.confirm("t", "**create an agent**", undefined, "admin");
|
|
356
|
-
return { fake, cards, answer };
|
|
357
|
-
};
|
|
358
|
-
|
|
359
|
-
test("an approval for admin tools shows who may approve and takes an admin's press", async () => {
|
|
360
|
-
const { fake, cards, answer } = askAdminApproval();
|
|
361
|
-
await tick();
|
|
362
|
-
expect(json(fake.sent[0])).toContain(messages().cardApproversNote("admin"));
|
|
363
|
-
const admin = press("button", `${CARD_PREFIX}${fake.cardId()}:yes`, {
|
|
364
|
-
user: ADMIN,
|
|
365
|
-
});
|
|
366
|
-
await cards.handle(admin.interaction);
|
|
367
|
-
expect(await answer).toBe("approved");
|
|
368
|
-
expect(admin.replies).toEqual([]);
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
test("the owner may approve it too, but a member may not", async () => {
|
|
372
|
-
const { fake, cards, answer } = askAdminApproval();
|
|
373
|
-
await tick();
|
|
374
|
-
const member = press("button", `${CARD_PREFIX}${fake.cardId()}:yes`, {
|
|
375
|
-
user: MEMBER,
|
|
376
|
-
roles: [ROLE],
|
|
377
|
-
});
|
|
378
|
-
await cards.handle(member.interaction);
|
|
379
|
-
expect(member.replies).toEqual([messages().cardApproversRefusal("admin")]);
|
|
380
|
-
const owner = press("button", `${CARD_PREFIX}${fake.cardId()}:no`, {
|
|
381
|
-
user: OWNER,
|
|
382
|
-
});
|
|
383
|
-
await cards.handle(owner.interaction);
|
|
384
|
-
expect(await answer).toBe("declined");
|
|
385
|
-
});
|
|
386
|
-
|
|
387
|
-
test("a shell approval stays the owner's even when the speaker is an admin", async () => {
|
|
388
|
-
const fake = fakeChannel();
|
|
389
|
-
const cards = new OwnerCards({
|
|
390
|
-
ownerId: OWNER,
|
|
391
|
-
speakers,
|
|
392
|
-
channel: fake.channel,
|
|
393
|
-
logger: silentLogger(),
|
|
394
|
-
});
|
|
395
|
-
const speaker = { id: ADMIN, name: "Ada", tier: "admin" } as const;
|
|
396
|
-
void cards.prompts("discord:555", speaker)?.confirm("t", "**rm**");
|
|
397
|
-
await tick();
|
|
398
|
-
const admin = press("button", `${CARD_PREFIX}${fake.cardId()}:yes`, {
|
|
399
|
-
user: ADMIN,
|
|
400
|
-
});
|
|
401
|
-
await cards.handle(admin.interaction);
|
|
402
|
-
expect(admin.replies).toEqual([messages().cardOwnerOnly]);
|
|
403
|
-
});
|
|
404
|
-
|
|
405
|
-
test("a question is for the speaker who is asked, and the owner", async () => {
|
|
406
|
-
const fake = fakeChannel();
|
|
407
|
-
const cards = new OwnerCards({
|
|
408
|
-
ownerId: OWNER,
|
|
409
|
-
speakers,
|
|
410
|
-
channel: fake.channel,
|
|
411
|
-
logger: silentLogger(),
|
|
412
|
-
});
|
|
413
|
-
const speaker = { id: ADMIN, name: "Ada", tier: "admin" } as const;
|
|
414
|
-
const question = {
|
|
415
|
-
question: "Which one?",
|
|
416
|
-
options: [{ label: "First" }, { label: "Second" }],
|
|
417
|
-
multi: false,
|
|
418
|
-
allowOther: false,
|
|
419
|
-
};
|
|
420
|
-
const answer = cards.prompts("discord:555", speaker)?.ask("t", question);
|
|
421
|
-
await tick();
|
|
422
|
-
const stranger = press("select", `${CARD_PREFIX}${fake.cardId()}:pick`, {
|
|
423
|
-
user: MEMBER,
|
|
424
|
-
values: ["0"],
|
|
425
|
-
});
|
|
426
|
-
await cards.handle(stranger.interaction);
|
|
427
|
-
expect(stranger.replies).toEqual([messages().cardAskerRefusal]);
|
|
428
|
-
const asked = press("select", `${CARD_PREFIX}${fake.cardId()}:pick`, {
|
|
429
|
-
user: ADMIN,
|
|
430
|
-
values: ["1"],
|
|
431
|
-
});
|
|
432
|
-
await cards.handle(asked.interaction);
|
|
433
|
-
expect(await answer).toEqual({ choices: ["Second"] });
|
|
434
|
-
});
|
|
435
|
-
});
|