@dsdenes/devboard 0.1.560 → 0.1.562
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/AGENTS.md +59 -0
- package/README.md +2497 -26
- package/bin/devboard.mjs +352 -118
- package/client/App.svelte +523 -0
- package/client/FeedbackOverlay.svelte +1125 -0
- package/client/FlowMode.svelte +1015 -0
- package/client/app.css +3922 -0
- package/client/env.d.ts +23 -0
- package/client/lib/commands.ts +836 -0
- package/client/lib/components/ActivityBar.svelte +295 -0
- package/client/lib/components/AdrTable.svelte +241 -0
- package/client/lib/components/CommandPalette.svelte +198 -0
- package/client/lib/components/ConceptKind.svelte +66 -0
- package/client/lib/components/ConfirmDialog.svelte +62 -0
- package/client/lib/components/DataTable.svelte +243 -0
- package/client/lib/components/DetailSplit.svelte +68 -0
- package/client/lib/components/DiscoverOverview.svelte +898 -0
- package/client/lib/components/DiscoverWorkspace.svelte +437 -0
- package/client/lib/components/DraftsPage.svelte +186 -0
- package/client/lib/components/GoalWorkspace.svelte +1042 -0
- package/client/lib/components/IssuesAgentFocus.svelte +117 -0
- package/client/lib/components/IssuesAgentLog.svelte +50 -0
- package/client/lib/components/IssuesQuestion.svelte +95 -0
- package/client/lib/components/IssuesWorkspace.svelte +577 -0
- package/client/lib/components/MermaidDiagram.svelte +89 -0
- package/client/lib/components/MonacoEditor.svelte +190 -0
- package/client/lib/components/NotificationRegion.svelte +195 -0
- package/client/lib/components/PaneHandle.svelte +25 -0
- package/client/lib/components/ScoutIssues.svelte +215 -0
- package/client/lib/components/ScoutWorkspace.svelte +180 -0
- package/client/lib/components/Sidebar.svelte +622 -0
- package/client/lib/components/StatusBar.svelte +118 -0
- package/client/lib/components/Terminal.svelte +193 -0
- package/client/lib/components/WatchdogWorkspace.svelte +537 -0
- package/client/lib/components/ui/button.svelte +56 -0
- package/client/lib/components/ui/icon-button.svelte +56 -0
- package/client/lib/components/ui/input.svelte +19 -0
- package/client/lib/components/ui/page-header.svelte +120 -0
- package/client/lib/components/ui/stage-track.svelte +141 -0
- package/client/lib/components/watchdog-indicator.svelte +189 -0
- package/client/lib/connection.svelte.ts +210 -0
- package/client/lib/dependency-diagram.test.ts +90 -0
- package/client/lib/dependency-diagram.ts +75 -0
- package/client/lib/discover-format.test.ts +265 -0
- package/client/lib/discover-format.ts +394 -0
- package/client/lib/discover.svelte.ts +412 -0
- package/client/lib/draft-format.test.ts +48 -0
- package/client/lib/draft-format.ts +13 -0
- package/client/lib/flow-save.test.ts +148 -0
- package/client/lib/flow.svelte.ts +445 -0
- package/client/lib/goal-reconnect.test.ts +282 -0
- package/client/lib/goal.svelte.ts +652 -0
- package/client/lib/issues.svelte.ts +439 -0
- package/client/lib/layout.svelte.ts +234 -0
- package/client/lib/notification-issue.test.ts +53 -0
- package/client/lib/notification-issue.ts +27 -0
- package/client/lib/notifications.svelte.ts +62 -0
- package/client/lib/poll-status.test.ts +141 -0
- package/client/lib/poll-status.ts +88 -0
- package/client/lib/record-status.test.ts +19 -0
- package/client/lib/record-status.ts +25 -0
- package/client/lib/scout.svelte.ts +164 -0
- package/client/lib/skill-document.test.ts +77 -0
- package/client/lib/skill-document.ts +57 -0
- package/client/lib/status-tone.test.ts +40 -0
- package/client/lib/status-tone.ts +33 -0
- package/client/lib/types.ts +578 -0
- package/client/lib/utils.ts +6 -0
- package/client/lib/viewport.svelte.ts +20 -0
- package/client/lib/watchdog.svelte.ts +69 -0
- package/client/main.ts +7 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/abnfDiagram-VCTEODGH-CmBSMArZ.js +1 -0
- package/dist/assets/arc-BZgCSX2H.js +1 -0
- package/dist/assets/architecture-7GRP2DOG-CF9MHlfo.js +1 -0
- package/dist/assets/architectureDiagram-5GKGNRK7-DzWB6xiQ.js +36 -0
- package/dist/assets/array-BifhSqXX.js +1 -0
- package/dist/assets/blockDiagram-I7D4REHJ-BtmGJ0hn.js +129 -0
- package/dist/assets/c4Diagram-7LVT6UL2-D5CHrrRX.js +38 -0
- package/dist/assets/channel-CgM4lcUW.js +1 -0
- package/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/dist/assets/chunk-4HAMMTFA-DeIufjFl.js +62 -0
- package/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/dist/assets/chunk-75Z2AOVW-DbtzyKUg.js +2 -0
- package/dist/assets/chunk-DU6HZSFF-C0fdbx3K.js +127 -0
- package/dist/assets/chunk-F27PBJKO-HObL2IvF.js +1 -0
- package/dist/assets/chunk-FOHPRMQF-DD4dKubD.js +161 -0
- package/dist/assets/chunk-GMAD6QVW-fg8wvtPY.js +72 -0
- package/dist/assets/chunk-GVQU2GXP-C0TaHOrD.js +1 -0
- package/dist/assets/chunk-IMKFNOWR-BVoIdx20.js +231 -0
- package/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/dist/assets/chunk-L3NEJ4N5-B_kmqfk5.js +1 -0
- package/dist/assets/chunk-OSK3NFVY-DO0vhxHH.js +10 -0
- package/dist/assets/chunk-P2QGCYS3-CjljLvFj.js +1 -0
- package/dist/assets/chunk-POPQ4Y6H-BU0C7tw6.js +1 -0
- package/dist/assets/chunk-PWAF6VOD-Dn0OjBt0.js +1 -0
- package/dist/assets/chunk-SHT3W25Y-CMCZE2xx.js +168 -0
- package/dist/assets/chunk-SVP7TREG-BIRmRy_W.js +88 -0
- package/dist/assets/chunk-TICWLB2K-C1bc2CDE.js +206 -0
- package/dist/assets/chunk-XXDRQBXY-Cc38QUmR.js +1 -0
- package/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/dist/assets/classDiagram-ZZMXUADV-BDh5w6Nc.js +1 -0
- package/dist/assets/classDiagram-v2-VYDZK3BY-BDh5w6Nc.js +1 -0
- package/dist/assets/cose-bilkent-JH36ORCC-B7pU7qhM.js +1 -0
- package/dist/assets/cynefin-OW5HDTMX-76dswSqS.js +1 -0
- package/dist/assets/cynefinDiagram-5FMLGOSQ-2kS4q01d.js +62 -0
- package/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
- package/dist/assets/dagre-D7JM2Yhv.js +1 -0
- package/dist/assets/dagre-GXQ25YYZ-B5Y21TQM.js +4 -0
- package/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/dist/assets/diagram-S7CK7UJ4-CY6td7Et.js +30 -0
- package/dist/assets/diagram-UQ7AKVKN-DmDqnGZf.js +41 -0
- package/dist/assets/diagram-VSXAHHWV-KGLWOOcE.js +3 -0
- package/dist/assets/diagram-VX7I27RA-D5syUraJ.js +24 -0
- package/dist/assets/diagram-Z3DM3KII-Dtrbs1Om.js +24 -0
- package/dist/assets/dist-Dce-2YbA.js +1 -0
- package/dist/assets/ebnfDiagram-PWID7BFC-CQpJLtU3.js +1 -0
- package/dist/assets/editor.worker-BmdWdrHZ.js +33 -0
- package/dist/assets/erDiagram-RLTQ6QDP-cMTzKLu_.js +99 -0
- package/dist/assets/eventmodeling-NTZA5JFV-CMzLdNaL.js +1 -0
- package/dist/assets/flowDiagram-HODETNUW-BMUajB-G.js +1 -0
- package/dist/assets/ganttDiagram-EL5Y4UJY-DseBS5Fs.js +292 -0
- package/dist/assets/gitGraph-4MIJSDKK-CUQZRpxJ.js +1 -0
- package/dist/assets/gitGraphDiagram-WWUBYQGX-CfMfsJ3b.js +106 -0
- package/dist/assets/graphlib-DS17s2tU.js +1 -0
- package/dist/assets/index-ByQ-XX38.css +1 -0
- package/dist/assets/index-CIf5TpfY.js +214 -0
- package/dist/assets/info-A6RAGUB7-D3nAKZ8M.js +1 -0
- package/dist/assets/infoDiagram-27XIBGKW-zu1O0WOy.js +2 -0
- package/dist/assets/init-C-OQMol4.js +1 -0
- package/dist/assets/ishikawaDiagram-5VMMS53U-DKC7BO9p.js +70 -0
- package/dist/assets/journeyDiagram-3NMN7TZE-Ld5mXse7.js +139 -0
- package/dist/assets/kanban-definition-UXKFOSKX-CH7_QlHL.js +89 -0
- package/dist/assets/katex-DJhrJSbw.js +257 -0
- package/dist/assets/line-N9XiCREw.js +1 -0
- package/dist/assets/linear-C9hnqmJv.js +1 -0
- package/dist/assets/mermaid-parser.core-DgPwV5Bh.js +7 -0
- package/dist/assets/mermaid.core-BKqynXLR.js +44 -0
- package/dist/assets/mindmap-definition-YA3MSWOX-DkiL-wiW.js +96 -0
- package/dist/assets/monaco-DjpsE8to.css +1 -0
- package/dist/assets/monaco-DqzJaNIe.js +908 -0
- package/dist/assets/ordinal-BDEzSJ7C.js +1 -0
- package/dist/assets/packet-AYTQ26CC-DuMTvpEJ.js +1 -0
- package/dist/assets/path-fybaL0A-.js +1 -0
- package/dist/assets/pegDiagram-XKGWAZYB-DvMl_xSe.js +1 -0
- package/dist/assets/pie-WAS4IAKB-Da0xPaD8.js +1 -0
- package/dist/assets/pieDiagram-E7YTZNPT-amANNRJY.js +39 -0
- package/dist/assets/quadrantDiagram-AXDQQJYC-BrgvDMnk.js +7 -0
- package/dist/assets/radar-RG4KPBEZ-Dz3Ih5-N.js +1 -0
- package/dist/assets/railroad-74A4TZTK-CKMW-IV2.js +1 -0
- package/dist/assets/railroad-abnf-HS5TGJTU-ChqmSWSF.js +1 -0
- package/dist/assets/railroad-ebnf-LZEXJU2U-V3R4fFdb.js +1 -0
- package/dist/assets/railroad-peg-WCYAUIDC-B-RuhUQW.js +1 -0
- package/dist/assets/railroadDiagram-O6MQD6OU-FjG-6OLu.js +1 -0
- package/dist/assets/requirementDiagram-BXWQKSXE-Cnj8RfE4.js +84 -0
- package/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/dist/assets/sankeyDiagram-P5KCCOFB-DMu1fnEh.js +40 -0
- package/dist/assets/sequenceDiagram-WJ2MYXX4-CtCtaRpW.js +162 -0
- package/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
- package/dist/assets/src-CGp3bfio.js +1 -0
- package/dist/assets/stateDiagram-D77RDMKH-xcJbLNhz.js +1 -0
- package/dist/assets/stateDiagram-v2-MP3YSRHH-DYHrQmJJ.js +1 -0
- package/dist/assets/swimlanes-42K2YHIH-BwLln_ga.js +1 -0
- package/dist/assets/swimlanesDiagram-VR7AAH4N-BcDfUQ0A.js +8 -0
- package/dist/assets/timeline-definition-24CTP7MA-ucNGsUUz.js +120 -0
- package/dist/assets/treeView-Q6P3EWNA-3uOomRue.js +1 -0
- package/dist/assets/treemap-WGGIJYW6-iGK7vwID.js +1 -0
- package/dist/assets/vennDiagram-4TSXK5OY-B9LgGe16.js +34 -0
- package/dist/assets/wardley-WFR3VGLG-FupMOf6k.js +1 -0
- package/dist/assets/wardleyDiagram-VM6X3IG4-CGZdEXM2.js +78 -0
- package/dist/assets/xterm-BrP-ENHg.css +1 -0
- package/dist/assets/xterm-C8zLTNGk.js +16 -0
- package/dist/assets/xychartDiagram-S5SC5T6Z-DafZrSBJ.js +7 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.svg +6 -0
- package/dist/index.html +34 -0
- package/extensions/implement-goal-issue.ts +368 -0
- package/extensions/implement-next-epic.ts +835 -0
- package/index.html +28 -0
- package/install.sh +111 -0
- package/issues/control.ts +91 -0
- package/issues/guards.ts +12 -0
- package/issues/ledger.ts +122 -0
- package/issues/legacy.ts +68 -0
- package/issues/lock.ts +305 -0
- package/issues/paths.ts +76 -0
- package/issues/preflight.ts +189 -0
- package/issues/repository.ts +203 -0
- package/issues/scout/actions.ts +255 -0
- package/issues/scout/control.ts +80 -0
- package/issues/scout/github.ts +358 -0
- package/issues/scout/paths.ts +129 -0
- package/issues/scout/preflight.ts +277 -0
- package/issues/scout/recommendation.ts +400 -0
- package/issues/scout/scan.ts +143 -0
- package/issues/scout/state.ts +448 -0
- package/issues/scout/worker.ts +1282 -0
- package/issues/state.ts +584 -0
- package/issues/tracker.ts +271 -0
- package/issues/worker.ts +1065 -0
- package/lib/dependencies.ts +61 -0
- package/lib/forge-auth.ts +953 -0
- package/lib/forge.ts +141 -0
- package/lib/gitea.ts +709 -0
- package/lib/github-release.ts +318 -0
- package/lib/inventory.test.ts +79 -0
- package/lib/inventory.ts +53 -0
- package/lib/release.test.ts +269 -0
- package/lib/release.ts +801 -0
- package/omp/commands/adr-resolve.md +8 -0
- package/omp/commands/discover.md +6 -0
- package/omp/commands/epic-next.md +8 -0
- package/omp/commands/flow-change.md +8 -0
- package/omp/commands/flow-goal.md +8 -0
- package/omp/commands/flow-implement.md +8 -0
- package/omp/commands/flow-widget.md +8 -0
- package/omp/commands/goal-draft.md +8 -0
- package/omp/commands/goal-outcome.md +8 -0
- package/omp/commands/implement-issue.md +8 -0
- package/omp/commands/prepare-skills.md +6 -0
- package/omp/commands/scout-recommendations.md +8 -0
- package/omp/commands/setup-devboard.md +24 -0
- package/omp/config.yml +12 -0
- package/omp/extensions/implement-goal-issue.ts +2 -0
- package/omp/extensions/implement-next-epic.ts +2 -0
- package/omp/mcp.json +9 -0
- package/package.json +86 -7
- package/prompts/adr-resolve.md +130 -0
- package/prompts/discover.md +541 -0
- package/prompts/epic-next.md +533 -0
- package/prompts/flow-change.md +76 -0
- package/prompts/flow-goal.md +126 -0
- package/prompts/flow-implement.md +103 -0
- package/prompts/flow-widget.md +54 -0
- package/prompts/goal-draft.md +147 -0
- package/prompts/goal-outcome.md +87 -0
- package/prompts/implement-issue.md +66 -0
- package/prompts/prepare-skills.md +142 -0
- package/prompts/scout-recommendations.md +143 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +6 -0
- package/server/acp.ts +540 -0
- package/server/activity.test.ts +96 -0
- package/server/activity.ts +48 -0
- package/server/app.test.ts +882 -0
- package/server/app.ts +6179 -0
- package/server/ask.test.ts +543 -0
- package/server/ask.ts +589 -0
- package/server/dependencies.test.ts +118 -0
- package/server/discover-remote.test.ts +1602 -0
- package/server/discover-sheet.test.ts +176 -0
- package/server/discover.test.ts +1510 -0
- package/server/discover.ts +3198 -0
- package/server/feedback.test.ts +458 -0
- package/server/feedback.ts +369 -0
- package/server/fixtures/adr-source.ts +71 -0
- package/server/fixtures/hang-download.ts +14 -0
- package/server/fixtures/stub-agent.ts +1870 -0
- package/server/fixtures/stub-forge.ts +141 -0
- package/server/fixtures/stub-gh.ts +57 -0
- package/server/flow-pr.test.ts +976 -0
- package/server/flow.test.ts +1632 -0
- package/server/flow.ts +2655 -0
- package/server/forge-pr.ts +387 -0
- package/server/goal-creation.test.ts +957 -0
- package/server/goal-draft.test.ts +348 -0
- package/server/goal-drafts.ts +449 -0
- package/server/goal-proposal.test.ts +479 -0
- package/server/goal-proposal.ts +232 -0
- package/server/goal-revision.test.ts +613 -0
- package/server/goal-slug.test.ts +56 -0
- package/server/goal-slug.ts +96 -0
- package/server/goal.ts +20 -0
- package/server/index.ts +147 -0
- package/server/issue-close.test.ts +100 -0
- package/server/issue-close.ts +178 -0
- package/server/issues.test.ts +167 -0
- package/server/issues.ts +479 -0
- package/server/journal.test.ts +350 -0
- package/server/journal.ts +329 -0
- package/server/lifecycle-pause.test.ts +1795 -0
- package/server/lifecycle.test.ts +1879 -0
- package/server/parallel-lifecycle.test.ts +1029 -0
- package/server/persistence-restart.test.ts +723 -0
- package/server/persistence.test.ts +1935 -0
- package/server/records.test.ts +180 -0
- package/server/records.ts +99 -0
- package/server/reset-implementation.test.ts +997 -0
- package/server/run.test.ts +611 -0
- package/server/scout.test.ts +877 -0
- package/server/scout.ts +459 -0
- package/server/skills-preparation.test.ts +1073 -0
- package/server/state.ts +249 -0
- package/server/tracker.ts +369 -0
- package/server/updates.test.ts +444 -0
- package/server/updates.ts +198 -0
- package/server/version.test.ts +140 -0
- package/server/version.ts +151 -0
- package/server/watchdog.ts +389 -0
- package/svelte.config.js +5 -0
- package/templates/host-AGENTS.md +235 -0
- package/tsconfig.json +21 -0
- package/vite.config.ts +21 -0
- package/watchdog/cleanup-main.ts +98 -0
- package/watchdog/cleanup.ts +301 -0
- package/watchdog/collect.ts +311 -0
- package/watchdog/coordinator.ts +397 -0
- package/watchdog/delivery.ts +107 -0
- package/watchdog/discovery.ts +209 -0
- package/watchdog/findings.ts +211 -0
- package/watchdog/github.ts +189 -0
- package/watchdog/history.ts +138 -0
- package/watchdog/install.ts +103 -0
- package/watchdog/ledger.ts +188 -0
- package/watchdog/lock.ts +63 -0
- package/watchdog/main.ts +106 -0
- package/watchdog/status.ts +71 -0
- package/watchdog/systemd.ts +96 -0
- package/watchdog/uninstall.ts +30 -0
- package/watchdog/units/devboard-watchdog.service +9 -0
- package/watchdog/units/devboard-watchdog.timer +10 -0
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
import { fixtureRecord, pendingRecord } from "./fixtures/adr-source";
|
|
2
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
3
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
4
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import type { Server } from "bun";
|
|
8
|
+
import { createApp } from "./app";
|
|
9
|
+
import type { EpicLookup, OpenEpic } from "./tracker";
|
|
10
|
+
|
|
11
|
+
const temporaryDirectories: string[] = [];
|
|
12
|
+
const STUB_AGENT = join(import.meta.dir, "fixtures", "stub-agent.ts");
|
|
13
|
+
const MANAGED_SKILL = [
|
|
14
|
+
"---",
|
|
15
|
+
"name: behavior-first-testing",
|
|
16
|
+
"description: Choose tests by observable behavior.",
|
|
17
|
+
"metadata:",
|
|
18
|
+
' devboard-managed: "true"',
|
|
19
|
+
"---",
|
|
20
|
+
"",
|
|
21
|
+
"# Behavior-first testing",
|
|
22
|
+
"",
|
|
23
|
+
"- Prefer observable behavior over implementation details.",
|
|
24
|
+
"",
|
|
25
|
+
].join("\n");
|
|
26
|
+
const QUESTION_OPTIONS = [
|
|
27
|
+
{ label: "Alpha", description: "Alpha leads to the first outcome.", recommended: true },
|
|
28
|
+
{ label: "Beta", description: "Beta leads to the second outcome.", recommended: false },
|
|
29
|
+
];
|
|
30
|
+
const REVISED_GOAL = "# Revised goal\n\nThe agent rewrote this.";
|
|
31
|
+
|
|
32
|
+
interface TestServer {
|
|
33
|
+
server: Server<unknown>;
|
|
34
|
+
adrDir: string;
|
|
35
|
+
goalFile: string;
|
|
36
|
+
logPath: string;
|
|
37
|
+
url: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface ServerOptions {
|
|
41
|
+
revision?: { writeGoal: boolean };
|
|
42
|
+
agentArguments?: string;
|
|
43
|
+
goalText?: string;
|
|
44
|
+
tracker?: EpicLookup;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function temporaryDirectory() {
|
|
48
|
+
const directory = await mkdtemp(join(tmpdir(), "devboard-goal-revision-"));
|
|
49
|
+
temporaryDirectories.push(directory);
|
|
50
|
+
return directory;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function startServer(
|
|
54
|
+
files: Record<string, string>,
|
|
55
|
+
options: ServerOptions = {},
|
|
56
|
+
): Promise<TestServer> {
|
|
57
|
+
const root = await temporaryDirectory();
|
|
58
|
+
const adrDir = join(root, "adrs");
|
|
59
|
+
const staticDir = join(root, "static");
|
|
60
|
+
const goalFile = join(root, "GOAL.md");
|
|
61
|
+
await mkdir(adrDir, { recursive: true });
|
|
62
|
+
await mkdir(staticDir, { recursive: true });
|
|
63
|
+
await writeFile(join(staticDir, "index.html"), "<!doctype html><title>ADR overview</title>");
|
|
64
|
+
if (options.goalText !== undefined) {
|
|
65
|
+
await writeFile(goalFile, options.goalText);
|
|
66
|
+
}
|
|
67
|
+
await mkdir(join(root, ".omp", "skills", "behavior-first-testing"), { recursive: true });
|
|
68
|
+
await writeFile(
|
|
69
|
+
join(root, ".omp", "skills", "behavior-first-testing", "SKILL.md"),
|
|
70
|
+
MANAGED_SKILL,
|
|
71
|
+
);
|
|
72
|
+
for (const [file, source] of Object.entries(files)) {
|
|
73
|
+
await writeFile(join(adrDir, file), source);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const logPath = join(root, "agent.log");
|
|
77
|
+
const command = [
|
|
78
|
+
`bun ${STUB_AGENT}`,
|
|
79
|
+
`--log=${logPath}`,
|
|
80
|
+
options.revision === undefined ? "" : "--revise-goal",
|
|
81
|
+
options.revision?.writeGoal === true ? `--goal=${goalFile}` : "",
|
|
82
|
+
options.agentArguments ?? "",
|
|
83
|
+
]
|
|
84
|
+
.filter((part) => part.length > 0)
|
|
85
|
+
.join(" ");
|
|
86
|
+
|
|
87
|
+
const server = createApp({
|
|
88
|
+
adrDir,
|
|
89
|
+
staticDir,
|
|
90
|
+
hostname: "127.0.0.1",
|
|
91
|
+
port: 0,
|
|
92
|
+
repoDir: root,
|
|
93
|
+
stateDir: join(root, "state"),
|
|
94
|
+
goalFile,
|
|
95
|
+
agentCommand: command,
|
|
96
|
+
tracker: options.tracker ?? (async () => undefined),
|
|
97
|
+
trackerSettle: { attempts: 1 },
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return { server, adrDir, goalFile, logPath, url: `127.0.0.1:${server.port}` };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface TestClient {
|
|
104
|
+
socket: WebSocket;
|
|
105
|
+
events: Record<string, unknown>[];
|
|
106
|
+
send(command: unknown): void;
|
|
107
|
+
waitFor(predicate: (message: Record<string, unknown>) => boolean): Promise<Record<string, unknown>>;
|
|
108
|
+
close(): void;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function connect(url: string): Promise<TestClient> {
|
|
112
|
+
const { promise, resolve, reject } = Promise.withResolvers<TestClient>();
|
|
113
|
+
const socket = new WebSocket(`ws://${url}/ws`);
|
|
114
|
+
const events: Record<string, unknown>[] = [];
|
|
115
|
+
const buffered: Record<string, unknown>[] = [];
|
|
116
|
+
const waiters: {
|
|
117
|
+
predicate: (message: Record<string, unknown>) => boolean;
|
|
118
|
+
resolve: (message: Record<string, unknown>) => void;
|
|
119
|
+
}[] = [];
|
|
120
|
+
|
|
121
|
+
socket.onopen = () => {
|
|
122
|
+
resolve({
|
|
123
|
+
socket,
|
|
124
|
+
events,
|
|
125
|
+
send(command) {
|
|
126
|
+
socket.send(JSON.stringify(command));
|
|
127
|
+
},
|
|
128
|
+
waitFor(predicate) {
|
|
129
|
+
const index = buffered.findIndex(predicate);
|
|
130
|
+
if (index >= 0) {
|
|
131
|
+
const message = buffered.splice(index, 1)[0];
|
|
132
|
+
if (message !== undefined) return Promise.resolve(message);
|
|
133
|
+
}
|
|
134
|
+
const { promise: waiting, resolve: settle } =
|
|
135
|
+
Promise.withResolvers<Record<string, unknown>>();
|
|
136
|
+
waiters.push({ predicate, resolve: settle });
|
|
137
|
+
return waiting;
|
|
138
|
+
},
|
|
139
|
+
close() {
|
|
140
|
+
socket.close();
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
socket.onmessage = (event) => {
|
|
146
|
+
let message: Record<string, unknown>;
|
|
147
|
+
try {
|
|
148
|
+
message = JSON.parse(String(event.data)) as Record<string, unknown>;
|
|
149
|
+
} catch {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
events.push(message);
|
|
153
|
+
const index = waiters.findIndex((waiter) => waiter.predicate(message));
|
|
154
|
+
if (index >= 0) {
|
|
155
|
+
const waiter = waiters.splice(index, 1)[0];
|
|
156
|
+
if (waiter !== undefined) waiter.resolve(message);
|
|
157
|
+
} else {
|
|
158
|
+
buffered.push(message);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
socket.onerror = () => reject(new Error("websocket failed"));
|
|
163
|
+
|
|
164
|
+
return promise;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function spawnCount(logPath: string): number {
|
|
168
|
+
if (!existsSync(logPath)) return 0;
|
|
169
|
+
return readFileSync(logPath, "utf8")
|
|
170
|
+
.split("\n")
|
|
171
|
+
.filter((line) => line.startsWith("spawn "))
|
|
172
|
+
.length;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function prompts(logPath: string): string[] {
|
|
176
|
+
if (!existsSync(logPath)) return [];
|
|
177
|
+
return readFileSync(logPath, "utf8")
|
|
178
|
+
.split("\n")
|
|
179
|
+
.filter((line) => line.startsWith("prompt "))
|
|
180
|
+
.map((line) => line.slice("prompt ".length));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function adrSnapshot(directory: string): [string, string][] {
|
|
184
|
+
return readdirSync(directory)
|
|
185
|
+
.sort()
|
|
186
|
+
.map((file) => [file, readFileSync(join(directory, file), "utf8")]);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function eventually(predicate: () => boolean, timeout = 5000) {
|
|
190
|
+
const deadline = Date.now() + timeout;
|
|
191
|
+
while (!predicate() && Date.now() < deadline) await Bun.sleep(25);
|
|
192
|
+
expect(predicate()).toBe(true);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function runEvents(client: TestClient): Record<string, unknown>[] {
|
|
196
|
+
return client.events.filter(
|
|
197
|
+
(message) => message.type === "runStarted" || message.type === "runStopped",
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
afterAll(async () => {
|
|
202
|
+
await Promise.all(
|
|
203
|
+
temporaryDirectories.map((directory) => rm(directory, { recursive: true, force: true })),
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const PENDING_ONE = pendingRecord("0001-first.md", "First decision", "First.", "Choose the first.");
|
|
208
|
+
const PENDING_TWO = pendingRecord("0002-second.md", "Second decision", "Second.", "Choose the second.");
|
|
209
|
+
|
|
210
|
+
describe("guided goal revision", () => {
|
|
211
|
+
test(
|
|
212
|
+
"revises the goal with guidance, relays activity and one question, and reports the written bytes",
|
|
213
|
+
async () => {
|
|
214
|
+
const originalGoal = "# Original goal\n";
|
|
215
|
+
const { server, adrDir, goalFile, logPath, url } = await startServer(
|
|
216
|
+
{ "0001-first.md": PENDING_ONE },
|
|
217
|
+
{ revision: { writeGoal: true }, goalText: originalGoal },
|
|
218
|
+
);
|
|
219
|
+
const client = await connect(url);
|
|
220
|
+
const adrsBefore = adrSnapshot(adrDir);
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
client.send({ type: "reviseGoal", request: "Tighten the goal" });
|
|
224
|
+
expect(await client.waitFor((message) => message.type === "goalRevisionStarted")).toEqual({
|
|
225
|
+
type: "goalRevisionStarted",
|
|
226
|
+
});
|
|
227
|
+
expect(
|
|
228
|
+
await client.waitFor(
|
|
229
|
+
(message) => message.type === "activity" && message.text === "Revising the goal",
|
|
230
|
+
),
|
|
231
|
+
).toEqual({ type: "activity", text: "Revising the goal" });
|
|
232
|
+
|
|
233
|
+
const question = await client.waitFor((message) => message.type === "question");
|
|
234
|
+
expect(question).toMatchObject({
|
|
235
|
+
message: "Choose an answer",
|
|
236
|
+
options: QUESTION_OPTIONS,
|
|
237
|
+
});
|
|
238
|
+
expect(prompts(logPath)).toHaveLength(1);
|
|
239
|
+
expect(readFileSync(logPath, "utf8")).toContain("Guidance: Tighten the goal");
|
|
240
|
+
|
|
241
|
+
client.send({ type: "answer", id: question.id, value: "Alpha" });
|
|
242
|
+
const stopped = await client.waitFor((message) => message.type === "goalRevisionStopped");
|
|
243
|
+
expect(stopped).toEqual({
|
|
244
|
+
type: "goalRevisionStopped",
|
|
245
|
+
completed: true,
|
|
246
|
+
text: REVISED_GOAL,
|
|
247
|
+
});
|
|
248
|
+
expect(await readFile(goalFile, "utf8")).toBe(REVISED_GOAL);
|
|
249
|
+
expect(stopped.text).toBe(await readFile(goalFile, "utf8"));
|
|
250
|
+
expect(
|
|
251
|
+
client.events
|
|
252
|
+
.filter((message) => message.type === "activity")
|
|
253
|
+
.map((message) => message.text),
|
|
254
|
+
).toEqual(["Revising the goal", "Answer received: Alpha", "Revised the goal"]);
|
|
255
|
+
|
|
256
|
+
expect(adrSnapshot(adrDir)).toEqual(adrsBefore);
|
|
257
|
+
expect(runEvents(client)).toEqual([]);
|
|
258
|
+
expect(
|
|
259
|
+
client.events.filter((message) => message.type === "error"),
|
|
260
|
+
).toEqual([]);
|
|
261
|
+
expect(spawnCount(logPath)).toBe(1);
|
|
262
|
+
} finally {
|
|
263
|
+
client.close();
|
|
264
|
+
server.stop(true);
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
20000,
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
test(
|
|
271
|
+
"starts the same flow without a request",
|
|
272
|
+
async () => {
|
|
273
|
+
const { server, goalFile, logPath, url } = await startServer(
|
|
274
|
+
{ "0001-first.md": PENDING_ONE },
|
|
275
|
+
{ revision: { writeGoal: true }, goalText: "# Original goal\n" },
|
|
276
|
+
);
|
|
277
|
+
const client = await connect(url);
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
client.send({ type: "reviseGoal" });
|
|
281
|
+
expect(await client.waitFor((message) => message.type === "goalRevisionStarted")).toEqual({
|
|
282
|
+
type: "goalRevisionStarted",
|
|
283
|
+
});
|
|
284
|
+
const question = await client.waitFor((message) => message.type === "question");
|
|
285
|
+
expect(question).toMatchObject({
|
|
286
|
+
message: "Choose an answer",
|
|
287
|
+
options: QUESTION_OPTIONS,
|
|
288
|
+
});
|
|
289
|
+
expect(prompts(logPath)[0]).not.toContain("Guidance:");
|
|
290
|
+
expect(readFileSync(logPath, "utf8")).not.toContain("Guidance:");
|
|
291
|
+
|
|
292
|
+
client.send({ type: "answer", id: question.id, value: "Beta" });
|
|
293
|
+
expect(await client.waitFor((message) => message.type === "goalRevisionStopped")).toEqual({
|
|
294
|
+
type: "goalRevisionStopped",
|
|
295
|
+
completed: true,
|
|
296
|
+
text: REVISED_GOAL,
|
|
297
|
+
});
|
|
298
|
+
expect(await readFile(goalFile, "utf8")).toBe(REVISED_GOAL);
|
|
299
|
+
expect(runEvents(client)).toEqual([]);
|
|
300
|
+
expect(spawnCount(logPath)).toBe(1);
|
|
301
|
+
} finally {
|
|
302
|
+
client.close();
|
|
303
|
+
server.stop(true);
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
20000,
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
test(
|
|
310
|
+
"refuses a revision while a lifecycle run is active without spawning a peer",
|
|
311
|
+
async () => {
|
|
312
|
+
const epic: OpenEpic = {
|
|
313
|
+
number: 42,
|
|
314
|
+
title: "Active epic",
|
|
315
|
+
url: "https://example.test/issues/42",
|
|
316
|
+
};
|
|
317
|
+
const { server, logPath, url } = await startServer(
|
|
318
|
+
{ "0001-done.md": fixtureRecord("0001-done.md", "Done") },
|
|
319
|
+
{
|
|
320
|
+
agentArguments: "--delay-ms=1200",
|
|
321
|
+
goalText: "# Active goal\n\nImplement the active epic.\n",
|
|
322
|
+
tracker: async () => epic,
|
|
323
|
+
},
|
|
324
|
+
);
|
|
325
|
+
const client = await connect(url);
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
client.send({ type: "startLifecycle" });
|
|
329
|
+
await client.waitFor(
|
|
330
|
+
(message) => message.type === "lifecycleState" && message.stage === "implementation",
|
|
331
|
+
);
|
|
332
|
+
await client.waitFor((message) => message.type === "runStarted");
|
|
333
|
+
await eventually(() => spawnCount(logPath) === 1);
|
|
334
|
+
|
|
335
|
+
client.send({ type: "reviseGoal", request: "Tighten the goal" });
|
|
336
|
+
expect(await client.waitFor((message) => message.type === "error")).toMatchObject({
|
|
337
|
+
message: "A lifecycle run is active.",
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
await Bun.sleep(200);
|
|
341
|
+
expect(spawnCount(logPath)).toBe(1);
|
|
342
|
+
expect(
|
|
343
|
+
client.events.filter((message) => message.type === "goalRevisionStarted"),
|
|
344
|
+
).toEqual([]);
|
|
345
|
+
} finally {
|
|
346
|
+
client.close();
|
|
347
|
+
server.stop(true);
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
20000,
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
test(
|
|
354
|
+
"refuses a revision while an ADR run is active without spawning a peer",
|
|
355
|
+
async () => {
|
|
356
|
+
const { server, logPath, url } = await startServer(
|
|
357
|
+
{ "0001-first.md": PENDING_ONE, "0002-second.md": PENDING_TWO },
|
|
358
|
+
{ agentArguments: "--delay-ms=1200 --questions=0" },
|
|
359
|
+
);
|
|
360
|
+
const client = await connect(url);
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
client.send({ type: "startRun" });
|
|
364
|
+
await client.waitFor(
|
|
365
|
+
(message) => message.type === "runStarted" && message.file === "0001-first.md",
|
|
366
|
+
);
|
|
367
|
+
await eventually(() => spawnCount(logPath) === 1);
|
|
368
|
+
|
|
369
|
+
client.send({ type: "reviseGoal" });
|
|
370
|
+
expect(await client.waitFor((message) => message.type === "error")).toMatchObject({
|
|
371
|
+
message: "A run is already active.",
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
await Bun.sleep(200);
|
|
375
|
+
expect(spawnCount(logPath)).toBe(1);
|
|
376
|
+
expect(
|
|
377
|
+
client.events.filter((message) => message.type === "goalRevisionStarted"),
|
|
378
|
+
).toEqual([]);
|
|
379
|
+
} finally {
|
|
380
|
+
client.close();
|
|
381
|
+
server.stop(true);
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
20000,
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
test(
|
|
388
|
+
"refuses a revision between records of one ADR sequence without spawning a peer",
|
|
389
|
+
async () => {
|
|
390
|
+
const { server, logPath, url } = await startServer(
|
|
391
|
+
{ "0001-first.md": PENDING_ONE, "0002-second.md": PENDING_TWO },
|
|
392
|
+
{ agentArguments: "--questions=0" },
|
|
393
|
+
);
|
|
394
|
+
const client = await connect(url);
|
|
395
|
+
|
|
396
|
+
try {
|
|
397
|
+
client.send({ type: "startRun" });
|
|
398
|
+
await client.waitFor(
|
|
399
|
+
(message) => message.type === "runStarted" && message.file === "0001-first.md",
|
|
400
|
+
);
|
|
401
|
+
expect(await client.waitFor((message) => message.type === "runStopped")).toMatchObject({
|
|
402
|
+
file: "0001-first.md",
|
|
403
|
+
resolved: true,
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
client.send({ type: "reviseGoal" });
|
|
407
|
+
expect(await client.waitFor((message) => message.type === "error")).toMatchObject({
|
|
408
|
+
message: "A run is already active.",
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
expect(
|
|
412
|
+
await client.waitFor((message) => message.type === "records" && message.next === null),
|
|
413
|
+
).toMatchObject({ next: null });
|
|
414
|
+
expect(spawnCount(logPath)).toBe(2);
|
|
415
|
+
expect(
|
|
416
|
+
client.events.filter((message) => message.type === "goalRevisionStarted"),
|
|
417
|
+
).toEqual([]);
|
|
418
|
+
expect(
|
|
419
|
+
client.events.filter(
|
|
420
|
+
(message) => message.type === "runStarted" && message.file === "0002-second.md",
|
|
421
|
+
),
|
|
422
|
+
).toHaveLength(1);
|
|
423
|
+
} finally {
|
|
424
|
+
client.close();
|
|
425
|
+
server.stop(true);
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
20000,
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
test(
|
|
432
|
+
"refuses startRun, startLifecycle, and a second revision while a revision is active",
|
|
433
|
+
async () => {
|
|
434
|
+
const { server, goalFile, logPath, url } = await startServer(
|
|
435
|
+
{ "0001-first.md": PENDING_ONE },
|
|
436
|
+
{
|
|
437
|
+
revision: { writeGoal: true },
|
|
438
|
+
goalText: "# Original goal\n",
|
|
439
|
+
agentArguments: "--delay-ms=600",
|
|
440
|
+
},
|
|
441
|
+
);
|
|
442
|
+
const client = await connect(url);
|
|
443
|
+
|
|
444
|
+
try {
|
|
445
|
+
client.send({ type: "reviseGoal", request: "Tighten the goal" });
|
|
446
|
+
await client.waitFor((message) => message.type === "goalRevisionStarted");
|
|
447
|
+
const question = await client.waitFor((message) => message.type === "question");
|
|
448
|
+
await eventually(() => spawnCount(logPath) === 1);
|
|
449
|
+
|
|
450
|
+
client.send({ type: "startRun" });
|
|
451
|
+
client.send({ type: "startLifecycle" });
|
|
452
|
+
client.send({ type: "reviseGoal" });
|
|
453
|
+
|
|
454
|
+
for (const expected of [
|
|
455
|
+
"A goal revision is active.",
|
|
456
|
+
"A goal revision is active.",
|
|
457
|
+
"A goal revision is already active.",
|
|
458
|
+
]) {
|
|
459
|
+
await client.waitFor(
|
|
460
|
+
(message) => message.type === "error" && message.message === expected,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
expect(spawnCount(logPath)).toBe(1);
|
|
465
|
+
expect(runEvents(client)).toEqual([]);
|
|
466
|
+
expect(
|
|
467
|
+
client.events.filter((message) => message.type === "lifecycleState"),
|
|
468
|
+
).toEqual([]);
|
|
469
|
+
|
|
470
|
+
client.send({ type: "answer", id: question.id, value: "Alpha" });
|
|
471
|
+
expect(await client.waitFor((message) => message.type === "goalRevisionStopped")).toEqual({
|
|
472
|
+
type: "goalRevisionStopped",
|
|
473
|
+
completed: true,
|
|
474
|
+
text: REVISED_GOAL,
|
|
475
|
+
});
|
|
476
|
+
expect(await readFile(goalFile, "utf8")).toBe(REVISED_GOAL);
|
|
477
|
+
expect(spawnCount(logPath)).toBe(1);
|
|
478
|
+
} finally {
|
|
479
|
+
client.close();
|
|
480
|
+
server.stop(true);
|
|
481
|
+
}
|
|
482
|
+
},
|
|
483
|
+
20000,
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
test(
|
|
487
|
+
"replays the revision start, activity so far, and pending question to a late client",
|
|
488
|
+
async () => {
|
|
489
|
+
const { server, goalFile, url } = await startServer(
|
|
490
|
+
{ "0001-first.md": PENDING_ONE },
|
|
491
|
+
{
|
|
492
|
+
revision: { writeGoal: true },
|
|
493
|
+
goalText: "# Original goal\n",
|
|
494
|
+
agentArguments: "--delay-ms=600",
|
|
495
|
+
},
|
|
496
|
+
);
|
|
497
|
+
const client = await connect(url);
|
|
498
|
+
|
|
499
|
+
try {
|
|
500
|
+
client.send({ type: "reviseGoal", request: "Tighten the goal" });
|
|
501
|
+
await client.waitFor((message) => message.type === "goalRevisionStarted");
|
|
502
|
+
await client.waitFor(
|
|
503
|
+
(message) => message.type === "activity" && message.text === "Revising the goal",
|
|
504
|
+
);
|
|
505
|
+
await client.waitFor((message) => message.type === "question");
|
|
506
|
+
|
|
507
|
+
const late = await connect(url);
|
|
508
|
+
try {
|
|
509
|
+
await eventually(() => late.events.length >= 3);
|
|
510
|
+
expect(late.events[0]).toEqual({ type: "goalRevisionStarted" });
|
|
511
|
+
expect(late.events[1]).toEqual({ type: "activity", text: "Revising the goal" });
|
|
512
|
+
expect(late.events[2]).toMatchObject({
|
|
513
|
+
type: "question",
|
|
514
|
+
message: "Choose an answer",
|
|
515
|
+
options: QUESTION_OPTIONS,
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
const question = late.events[2];
|
|
519
|
+
if (question === undefined) throw new Error("the revision question was not replayed");
|
|
520
|
+
late.send({ type: "answer", id: question.id, value: "Alpha" });
|
|
521
|
+
expect(await client.waitFor((message) => message.type === "goalRevisionStopped")).toEqual({
|
|
522
|
+
type: "goalRevisionStopped",
|
|
523
|
+
completed: true,
|
|
524
|
+
text: REVISED_GOAL,
|
|
525
|
+
});
|
|
526
|
+
expect(await readFile(goalFile, "utf8")).toBe(REVISED_GOAL);
|
|
527
|
+
} finally {
|
|
528
|
+
late.close();
|
|
529
|
+
}
|
|
530
|
+
} finally {
|
|
531
|
+
client.close();
|
|
532
|
+
server.stop(true);
|
|
533
|
+
}
|
|
534
|
+
},
|
|
535
|
+
20000,
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
test(
|
|
539
|
+
"reports a halt that leaves the goal unchanged as an incomplete revision",
|
|
540
|
+
async () => {
|
|
541
|
+
const existing = "# Original goal\n\nImplement the original.\n";
|
|
542
|
+
const { server, goalFile, logPath, url } = await startServer(
|
|
543
|
+
{ "0001-first.md": PENDING_ONE },
|
|
544
|
+
{ revision: { writeGoal: false }, goalText: existing },
|
|
545
|
+
);
|
|
546
|
+
const client = await connect(url);
|
|
547
|
+
|
|
548
|
+
try {
|
|
549
|
+
client.send({ type: "reviseGoal", request: "Tighten the goal" });
|
|
550
|
+
await client.waitFor((message) => message.type === "goalRevisionStarted");
|
|
551
|
+
const question = await client.waitFor((message) => message.type === "question");
|
|
552
|
+
client.send({ type: "answer", id: question.id, value: "Alpha" });
|
|
553
|
+
|
|
554
|
+
const error = await client.waitFor((message) => message.type === "error");
|
|
555
|
+
expect(error.message).toBe("The goal revision ended without changing the goal.");
|
|
556
|
+
const stopped = await client.waitFor((message) => message.type === "goalRevisionStopped");
|
|
557
|
+
expect(stopped).toEqual({ type: "goalRevisionStopped", completed: false });
|
|
558
|
+
expect("text" in stopped).toBe(false);
|
|
559
|
+
expect(await readFile(goalFile, "utf8")).toBe(existing);
|
|
560
|
+
expect(spawnCount(logPath)).toBe(1);
|
|
561
|
+
|
|
562
|
+
client.send({ type: "getGoal" });
|
|
563
|
+
expect(await client.waitFor((message) => message.type === "goal")).toEqual({
|
|
564
|
+
type: "goal",
|
|
565
|
+
text: existing,
|
|
566
|
+
});
|
|
567
|
+
} finally {
|
|
568
|
+
client.close();
|
|
569
|
+
server.stop(true);
|
|
570
|
+
}
|
|
571
|
+
},
|
|
572
|
+
20000,
|
|
573
|
+
);
|
|
574
|
+
|
|
575
|
+
test(
|
|
576
|
+
"reports an incomplete revision when the peer leaves no readable goal and still starts a later run",
|
|
577
|
+
async () => {
|
|
578
|
+
const { server, logPath, url } = await startServer(
|
|
579
|
+
{ "0001-first.md": PENDING_ONE },
|
|
580
|
+
{ revision: { writeGoal: false } },
|
|
581
|
+
);
|
|
582
|
+
const client = await connect(url);
|
|
583
|
+
|
|
584
|
+
try {
|
|
585
|
+
client.send({ type: "reviseGoal", request: "Tighten the goal" });
|
|
586
|
+
await client.waitFor((message) => message.type === "goalRevisionStarted");
|
|
587
|
+
const question = await client.waitFor((message) => message.type === "question");
|
|
588
|
+
client.send({ type: "answer", id: question.id, value: "Alpha" });
|
|
589
|
+
|
|
590
|
+
const error = await client.waitFor((message) => message.type === "error");
|
|
591
|
+
expect(String(error.message)).toContain("Could not read the revised goal");
|
|
592
|
+
const stopped = await client.waitFor((message) => message.type === "goalRevisionStopped");
|
|
593
|
+
expect(stopped).toEqual({ type: "goalRevisionStopped", completed: false });
|
|
594
|
+
expect("text" in stopped).toBe(false);
|
|
595
|
+
|
|
596
|
+
client.send({ type: "startRun" });
|
|
597
|
+
expect(await client.waitFor((message) => message.type === "runStarted")).toMatchObject({
|
|
598
|
+
file: "0001-first.md",
|
|
599
|
+
});
|
|
600
|
+
const runQuestion = await client.waitFor((message) => message.type === "question");
|
|
601
|
+
client.send({ type: "answer", id: runQuestion.id, value: "Alpha" });
|
|
602
|
+
expect(await client.waitFor((message) => message.type === "runStopped")).toMatchObject({
|
|
603
|
+
file: "0001-first.md",
|
|
604
|
+
});
|
|
605
|
+
expect(spawnCount(logPath)).toBe(2);
|
|
606
|
+
} finally {
|
|
607
|
+
client.close();
|
|
608
|
+
server.stop(true);
|
|
609
|
+
}
|
|
610
|
+
},
|
|
611
|
+
20000,
|
|
612
|
+
);
|
|
613
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { suggestGoalSlug } from "./goal-slug";
|
|
3
|
+
|
|
4
|
+
describe("suggestGoalSlug", () => {
|
|
5
|
+
test("returns the normalized assistant text", async () => {
|
|
6
|
+
const slug = await suggestGoalSlug("The Start button is clipped.", {
|
|
7
|
+
command: "stub",
|
|
8
|
+
cwd: ".",
|
|
9
|
+
connect: (options) => ({
|
|
10
|
+
run: async () => {
|
|
11
|
+
options.onText?.("`clipped-start-button`");
|
|
12
|
+
},
|
|
13
|
+
close: () => {},
|
|
14
|
+
}),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
expect(slug).toBe("clipped-start-button");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("rejects an unusable answer and closes the peer", async () => {
|
|
21
|
+
let closed = false;
|
|
22
|
+
await expect(
|
|
23
|
+
suggestGoalSlug("The Start button is clipped.", {
|
|
24
|
+
command: "stub",
|
|
25
|
+
cwd: ".",
|
|
26
|
+
connect: (options) => ({
|
|
27
|
+
run: async () => {
|
|
28
|
+
options.onText?.("I would call it the clipped start button.");
|
|
29
|
+
},
|
|
30
|
+
close: () => {
|
|
31
|
+
closed = true;
|
|
32
|
+
},
|
|
33
|
+
}),
|
|
34
|
+
}),
|
|
35
|
+
).rejects.toThrow("no usable slug");
|
|
36
|
+
expect(closed).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("times out a silent peer and closes it", async () => {
|
|
40
|
+
let closed = false;
|
|
41
|
+
await expect(
|
|
42
|
+
suggestGoalSlug("The Start button is clipped.", {
|
|
43
|
+
command: "stub",
|
|
44
|
+
cwd: ".",
|
|
45
|
+
timeoutMs: 10,
|
|
46
|
+
connect: () => ({
|
|
47
|
+
run: () => Promise.withResolvers<never>().promise,
|
|
48
|
+
close: () => {
|
|
49
|
+
closed = true;
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
52
|
+
}),
|
|
53
|
+
).rejects.toThrow("did not answer in time");
|
|
54
|
+
expect(closed).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
});
|