@dsdenes/devboard 0.1.560 → 0.1.563
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 +729 -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
package/server/acp.ts
ADDED
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
import type { Subprocess } from "bun";
|
|
2
|
+
|
|
3
|
+
export interface AcpQuestionOption {
|
|
4
|
+
label: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
recommended: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface AcpQuestion {
|
|
10
|
+
id: number | string;
|
|
11
|
+
message: string;
|
|
12
|
+
options: AcpQuestionOption[];
|
|
13
|
+
properties: Record<string, unknown>;
|
|
14
|
+
required: string[] | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AcpConnectionOptions {
|
|
18
|
+
command: string;
|
|
19
|
+
cwd: string;
|
|
20
|
+
onActivity: (activity: AcpActivity) => void;
|
|
21
|
+
onQuestion: (question: AcpQuestion) => void;
|
|
22
|
+
/** Receives assistant message text only; thoughts and tool activity are dropped. */
|
|
23
|
+
onText?: (text: string) => void;
|
|
24
|
+
onStderr?: (text: string) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface PendingRequest {
|
|
28
|
+
resolve: (value: unknown) => void;
|
|
29
|
+
reject: (error: Error) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface PendingQuestion extends AcpQuestion {
|
|
33
|
+
wireId: number | string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type JsonRpcMessage = {
|
|
37
|
+
jsonrpc?: string;
|
|
38
|
+
id?: number | string | null;
|
|
39
|
+
method?: string;
|
|
40
|
+
params?: unknown;
|
|
41
|
+
result?: unknown;
|
|
42
|
+
error?: { code?: number; message?: string; data?: unknown };
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const PROTOCOL_VERSION = 1;
|
|
46
|
+
const CLIENT_INFO = { name: "devboard-adr-ui", version: "0.1.0" };
|
|
47
|
+
|
|
48
|
+
/** JSON-RPC parameters arrive as parsed JSON; narrow once before member access. */
|
|
49
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
50
|
+
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface ParsedOption {
|
|
54
|
+
label: string;
|
|
55
|
+
description?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parsedOption(entry: unknown): ParsedOption | undefined {
|
|
59
|
+
if (typeof entry === "string") return { label: entry };
|
|
60
|
+
const record = asRecord(entry);
|
|
61
|
+
if (record === undefined) return undefined;
|
|
62
|
+
const label = [record.title, record.const, record.label, record.value].find(
|
|
63
|
+
(candidate): candidate is string => typeof candidate === "string",
|
|
64
|
+
);
|
|
65
|
+
if (label === undefined) return undefined;
|
|
66
|
+
const description =
|
|
67
|
+
typeof record.description === "string" && record.description.trim().length > 0
|
|
68
|
+
? record.description
|
|
69
|
+
: undefined;
|
|
70
|
+
return description === undefined ? { label } : { label, description };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function propertyOptions(property: unknown): ParsedOption[] {
|
|
74
|
+
const record = asRecord(property);
|
|
75
|
+
if (record === undefined) return [];
|
|
76
|
+
const containers = [record.enum, record.oneOf, record.anyOf];
|
|
77
|
+
const items = asRecord(record.items);
|
|
78
|
+
if (items !== undefined) {
|
|
79
|
+
containers.push(items.enum, items.oneOf, items.anyOf, items.const);
|
|
80
|
+
}
|
|
81
|
+
containers.push(record.const);
|
|
82
|
+
|
|
83
|
+
const options: ParsedOption[] = [];
|
|
84
|
+
const labels = new Set<string>();
|
|
85
|
+
for (const container of containers) {
|
|
86
|
+
const entries = Array.isArray(container) ? container : container === undefined ? [] : [container];
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
const option = parsedOption(entry);
|
|
89
|
+
if (option === undefined || labels.has(option.label)) continue;
|
|
90
|
+
labels.add(option.label);
|
|
91
|
+
options.push(option);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return options;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function propertyLabels(property: unknown): string[] {
|
|
98
|
+
return propertyOptions(property).map((option) => option.label);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function questionOptions(properties: Record<string, unknown>): AcpQuestionOption[] {
|
|
102
|
+
const options: AcpQuestionOption[] = [];
|
|
103
|
+
const labels = new Set<string>();
|
|
104
|
+
for (const [key, property] of Object.entries(properties)) {
|
|
105
|
+
if (key.endsWith("__other")) continue;
|
|
106
|
+
const record = asRecord(property);
|
|
107
|
+
const recommended = typeof record?.default === "string" ? record.default : undefined;
|
|
108
|
+
for (const option of propertyOptions(property)) {
|
|
109
|
+
if (labels.has(option.label)) continue;
|
|
110
|
+
labels.add(option.label);
|
|
111
|
+
options.push({ ...option, recommended: option.label === recommended });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// A question with choices always shows one recommendation: when the peer
|
|
115
|
+
// omits a default, the first option is the suggestion the UI marks.
|
|
116
|
+
const first = options[0];
|
|
117
|
+
if (first !== undefined && !options.some((option) => option.recommended)) {
|
|
118
|
+
first.recommended = true;
|
|
119
|
+
}
|
|
120
|
+
return options;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function answerContent(question: PendingQuestion, value: string): Record<string, unknown> {
|
|
124
|
+
const keys = Object.keys(question.properties);
|
|
125
|
+
const choiceKeys = keys.filter((key) => propertyLabels(question.properties[key]).length > 0);
|
|
126
|
+
const matchingKey = choiceKeys.find((key) => propertyLabels(question.properties[key]).includes(value));
|
|
127
|
+
|
|
128
|
+
if (matchingKey !== undefined) {
|
|
129
|
+
const property = asRecord(question.properties[matchingKey]);
|
|
130
|
+
return { [matchingKey]: property?.type === "array" ? [value] : value };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const primaryKey = choiceKeys[0];
|
|
134
|
+
const otherKey = primaryKey === undefined ? undefined : `${primaryKey}__other`;
|
|
135
|
+
if (otherKey !== undefined && Object.hasOwn(question.properties, otherKey)) {
|
|
136
|
+
return { [otherKey]: value };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (primaryKey !== undefined) return { [primaryKey]: value };
|
|
140
|
+
|
|
141
|
+
const key =
|
|
142
|
+
question.required?.find((candidate) => Object.hasOwn(question.properties, candidate)) ?? keys[0];
|
|
143
|
+
if (key === undefined) return {};
|
|
144
|
+
return { [key]: value };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** One entry in the developer-facing activity log. */
|
|
148
|
+
export interface AcpActivity {
|
|
149
|
+
text: string;
|
|
150
|
+
/** True when `text` continues the previous activity line instead of starting one. */
|
|
151
|
+
append: boolean;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The message whose streamed chunks are currently being joined. */
|
|
155
|
+
export interface ActivityStream {
|
|
156
|
+
kind: string;
|
|
157
|
+
messageId: string | undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Maps one ACP session update to an activity entry. Text updates arrive as
|
|
162
|
+
* deltas of one assistant message, identified by `messageId`; a delta that
|
|
163
|
+
* continues the stream already in progress is marked for appending so the log
|
|
164
|
+
* shows the message, not its chunks. Without a message id a text update is
|
|
165
|
+
* taken as a complete line.
|
|
166
|
+
*/
|
|
167
|
+
export function nextActivity(
|
|
168
|
+
update: unknown,
|
|
169
|
+
stream: ActivityStream | undefined,
|
|
170
|
+
): { activity: AcpActivity; stream: ActivityStream | undefined } | undefined {
|
|
171
|
+
const notification = asRecord(update);
|
|
172
|
+
if (notification === undefined) return undefined;
|
|
173
|
+
|
|
174
|
+
switch (notification.sessionUpdate) {
|
|
175
|
+
case "agent_message_chunk":
|
|
176
|
+
case "agent_thought_chunk":
|
|
177
|
+
case "user_message_chunk": {
|
|
178
|
+
const content = asRecord(notification.content);
|
|
179
|
+
const text = content?.text;
|
|
180
|
+
if (typeof text !== "string" || text.length === 0) return undefined;
|
|
181
|
+
const kind = notification.sessionUpdate;
|
|
182
|
+
const messageId =
|
|
183
|
+
typeof notification.messageId === "string" ? notification.messageId : undefined;
|
|
184
|
+
const append =
|
|
185
|
+
messageId !== undefined &&
|
|
186
|
+
stream !== undefined &&
|
|
187
|
+
stream.kind === kind &&
|
|
188
|
+
stream.messageId === messageId;
|
|
189
|
+
return { activity: { text, append }, stream: { kind, messageId } };
|
|
190
|
+
}
|
|
191
|
+
case "tool_call":
|
|
192
|
+
case "tool_call_update": {
|
|
193
|
+
const title = notification.title;
|
|
194
|
+
if (typeof title !== "string" || title.length === 0) return undefined;
|
|
195
|
+
return { activity: { text: title, append: false }, stream: undefined };
|
|
196
|
+
}
|
|
197
|
+
case "plan": {
|
|
198
|
+
const entries = notification.entries;
|
|
199
|
+
if (!Array.isArray(entries)) return undefined;
|
|
200
|
+
const lines = entries
|
|
201
|
+
.map((entry) => asRecord(entry)?.content)
|
|
202
|
+
.filter((line): line is string => typeof line === "string" && line.length > 0);
|
|
203
|
+
if (lines.length === 0) return undefined;
|
|
204
|
+
return { activity: { text: lines.join("\n"), append: false }, stream: undefined };
|
|
205
|
+
}
|
|
206
|
+
default:
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** True when the peer rejected a prompt because its previous turn is still running. */
|
|
212
|
+
export function isSessionBusy(error: unknown): boolean {
|
|
213
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === -32003;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* A minimal ACP client: JSON-RPC 2.0 over newline-delimited JSON on the child's
|
|
218
|
+
* stdio. It starts a session, sends one prompt, relays session updates as
|
|
219
|
+
* activity, and holds elicitation requests until the developer answers.
|
|
220
|
+
*/
|
|
221
|
+
export class AcpConnection {
|
|
222
|
+
readonly #process: Subprocess<"pipe", "pipe", "pipe">;
|
|
223
|
+
readonly #options: AcpConnectionOptions;
|
|
224
|
+
readonly #pending = new Map<string, PendingRequest>();
|
|
225
|
+
readonly #questions = new Map<string, PendingQuestion>();
|
|
226
|
+
readonly #argv: string[];
|
|
227
|
+
#nextRequestId = 1;
|
|
228
|
+
#closed = false;
|
|
229
|
+
#sessionId: string | undefined;
|
|
230
|
+
#activityStream: ActivityStream | undefined;
|
|
231
|
+
|
|
232
|
+
constructor(options: AcpConnectionOptions) {
|
|
233
|
+
this.#options = options;
|
|
234
|
+
const argv = options.command.trim().split(/\s+/).filter(Boolean);
|
|
235
|
+
if (argv.length === 0) throw new Error("AGENT_COMMAND is empty.");
|
|
236
|
+
this.#argv = argv;
|
|
237
|
+
|
|
238
|
+
this.#process = Bun.spawn(argv, {
|
|
239
|
+
cwd: options.cwd,
|
|
240
|
+
stdin: "pipe",
|
|
241
|
+
stdout: "pipe",
|
|
242
|
+
stderr: "pipe",
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
void this.#readStdout();
|
|
246
|
+
void this.#readStderr();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
get closed(): boolean {
|
|
250
|
+
return this.#closed;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The child's pid, recorded so a replacement can end an orphaned peer. */
|
|
254
|
+
get pid(): number | undefined {
|
|
255
|
+
return this.#closed ? undefined : this.#process.pid;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The exact argv the child was spawned with, used to prove its identity. */
|
|
259
|
+
get argv(): string[] {
|
|
260
|
+
return [...this.#argv];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async run(prompt: string): Promise<unknown> {
|
|
264
|
+
await this.#request("initialize", {
|
|
265
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
266
|
+
clientCapabilities: { elicitation: { form: {} } },
|
|
267
|
+
clientInfo: CLIENT_INFO,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const session = asRecord(
|
|
271
|
+
await this.#request("session/new", {
|
|
272
|
+
cwd: this.#options.cwd,
|
|
273
|
+
mcpServers: [],
|
|
274
|
+
}),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
const sessionId = session?.sessionId;
|
|
278
|
+
if (typeof sessionId !== "string") {
|
|
279
|
+
throw new Error("Agent did not return a session id.");
|
|
280
|
+
}
|
|
281
|
+
this.#sessionId = sessionId;
|
|
282
|
+
|
|
283
|
+
return await this.prompt(prompt);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Sends another prompt on the session this connection opened. */
|
|
287
|
+
async prompt(prompt: string): Promise<unknown> {
|
|
288
|
+
if (this.#sessionId === undefined) throw new Error("No agent session is open.");
|
|
289
|
+
return await this.#request("session/prompt", {
|
|
290
|
+
sessionId: this.#sessionId,
|
|
291
|
+
prompt: [{ type: "text", text: prompt }],
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
answer(id: number | string, value: string): boolean {
|
|
296
|
+
const key = String(id);
|
|
297
|
+
const question = this.#questions.get(key);
|
|
298
|
+
if (question === undefined) return false;
|
|
299
|
+
|
|
300
|
+
this.#questions.delete(key);
|
|
301
|
+
this.#respond(question.wireId, {
|
|
302
|
+
action: "accept",
|
|
303
|
+
content: answerContent(question, value),
|
|
304
|
+
});
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Suspends the child without ending its ACP session. The pipe buffers keep
|
|
310
|
+
* any answer written meanwhile, and the pending request survives.
|
|
311
|
+
*/
|
|
312
|
+
pause(): boolean {
|
|
313
|
+
if (this.#closed) return false;
|
|
314
|
+
try {
|
|
315
|
+
this.#process.kill("SIGSTOP");
|
|
316
|
+
return true;
|
|
317
|
+
} catch {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
resume(): boolean {
|
|
323
|
+
if (this.#closed) return false;
|
|
324
|
+
try {
|
|
325
|
+
this.#process.kill("SIGCONT");
|
|
326
|
+
return true;
|
|
327
|
+
} catch {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
close(): void {
|
|
333
|
+
if (this.#closed) return;
|
|
334
|
+
this.#closed = true;
|
|
335
|
+
try {
|
|
336
|
+
// A stopped child does not process SIGTERM until it runs again.
|
|
337
|
+
this.#process.kill("SIGCONT");
|
|
338
|
+
} catch {
|
|
339
|
+
// The process already exited.
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
this.#process.kill();
|
|
343
|
+
} catch {
|
|
344
|
+
// The process already exited.
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async #readStdout(): Promise<void> {
|
|
349
|
+
const reader = this.#process.stdout.getReader();
|
|
350
|
+
const decoder = new TextDecoder();
|
|
351
|
+
let buffer = "";
|
|
352
|
+
|
|
353
|
+
try {
|
|
354
|
+
while (true) {
|
|
355
|
+
const { done, value } = await reader.read();
|
|
356
|
+
if (done) break;
|
|
357
|
+
buffer += decoder.decode(value, { stream: true });
|
|
358
|
+
let newline = buffer.indexOf("\n");
|
|
359
|
+
while (newline >= 0) {
|
|
360
|
+
const line = buffer.slice(0, newline).trim();
|
|
361
|
+
buffer = buffer.slice(newline + 1);
|
|
362
|
+
if (line.length > 0) this.#handleLine(line);
|
|
363
|
+
newline = buffer.indexOf("\n");
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (buffer.trim().length > 0) this.#handleLine(buffer.trim());
|
|
367
|
+
} finally {
|
|
368
|
+
this.#closed = true;
|
|
369
|
+
for (const request of this.#pending.values()) {
|
|
370
|
+
request.reject(new Error("Agent process exited before answering."));
|
|
371
|
+
}
|
|
372
|
+
this.#pending.clear();
|
|
373
|
+
this.#questions.clear();
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async #readStderr(): Promise<void> {
|
|
378
|
+
const reader = this.#process.stderr.getReader();
|
|
379
|
+
const decoder = new TextDecoder();
|
|
380
|
+
try {
|
|
381
|
+
while (true) {
|
|
382
|
+
const { done, value } = await reader.read();
|
|
383
|
+
if (done) break;
|
|
384
|
+
const text = decoder.decode(value, { stream: true });
|
|
385
|
+
if (text.length > 0) this.#options.onStderr?.(text);
|
|
386
|
+
}
|
|
387
|
+
} catch {
|
|
388
|
+
// stderr is diagnostic only.
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
#handleLine(line: string): void {
|
|
393
|
+
let message: JsonRpcMessage;
|
|
394
|
+
try {
|
|
395
|
+
message = JSON.parse(line) as JsonRpcMessage;
|
|
396
|
+
} catch {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (typeof message.method === "string") {
|
|
401
|
+
if (message.id !== undefined && message.id !== null) {
|
|
402
|
+
this.#handleRequest(message.id, message.method, message.params);
|
|
403
|
+
} else if (message.method === "session/update") {
|
|
404
|
+
this.#handleSessionUpdate(message.params);
|
|
405
|
+
}
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (message.id === undefined || message.id === null) return;
|
|
410
|
+
const pending = this.#pending.get(String(message.id));
|
|
411
|
+
if (pending === undefined) return;
|
|
412
|
+
this.#pending.delete(String(message.id));
|
|
413
|
+
|
|
414
|
+
if (message.error !== undefined) {
|
|
415
|
+
const error: Error & { code?: number; data?: unknown } = new Error(
|
|
416
|
+
message.error.message ?? "Agent request failed.",
|
|
417
|
+
);
|
|
418
|
+
error.code = message.error.code;
|
|
419
|
+
error.data = message.error.data;
|
|
420
|
+
pending.reject(error);
|
|
421
|
+
} else {
|
|
422
|
+
pending.resolve(message.result);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
#handleRequest(id: number | string, method: string, params: unknown): void {
|
|
427
|
+
switch (method) {
|
|
428
|
+
case "session/update":
|
|
429
|
+
this.#handleSessionUpdate(params);
|
|
430
|
+
this.#respond(id, null);
|
|
431
|
+
return;
|
|
432
|
+
case "elicitation/create":
|
|
433
|
+
this.#handleElicitation(id, params);
|
|
434
|
+
return;
|
|
435
|
+
case "session/request_permission": {
|
|
436
|
+
const request = asRecord(params) ?? {};
|
|
437
|
+
const options = Array.isArray(request.options) ? request.options : [];
|
|
438
|
+
const option = options.find((candidate) => {
|
|
439
|
+
const kind = asRecord(candidate)?.kind;
|
|
440
|
+
return typeof kind === "string" && kind.startsWith("allow");
|
|
441
|
+
});
|
|
442
|
+
const optionId = asRecord(option)?.optionId;
|
|
443
|
+
this.#respond(
|
|
444
|
+
id,
|
|
445
|
+
typeof optionId === "string"
|
|
446
|
+
? { outcome: { outcome: "selected", optionId } }
|
|
447
|
+
: { outcome: { outcome: "cancelled" } },
|
|
448
|
+
);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
default:
|
|
452
|
+
this.#respondError(id, -32601, `Method not found: ${method}`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
#handleSessionUpdate(params: unknown): void {
|
|
457
|
+
const next = nextActivity(asRecord(params)?.update, this.#activityStream);
|
|
458
|
+
if (next === undefined) return;
|
|
459
|
+
this.#activityStream = next.stream;
|
|
460
|
+
this.#options.onActivity(next.activity);
|
|
461
|
+
if (next.stream?.kind === "agent_message_chunk") {
|
|
462
|
+
this.#options.onText?.(next.activity.text);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
#handleElicitation(wireId: number | string, params: unknown): void {
|
|
467
|
+
const request = asRecord(params);
|
|
468
|
+
if (request === undefined) {
|
|
469
|
+
this.#respond(wireId, { action: "cancel" });
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const schema = asRecord(request.requestedSchema);
|
|
474
|
+
const properties = asRecord(schema?.properties) ?? {};
|
|
475
|
+
const required = Array.isArray(schema?.required)
|
|
476
|
+
? schema.required.filter((value): value is string => typeof value === "string")
|
|
477
|
+
: undefined;
|
|
478
|
+
const message = typeof request.message === "string" ? request.message : "";
|
|
479
|
+
|
|
480
|
+
const question: PendingQuestion = {
|
|
481
|
+
id: wireId,
|
|
482
|
+
wireId,
|
|
483
|
+
message,
|
|
484
|
+
options: questionOptions(properties),
|
|
485
|
+
properties,
|
|
486
|
+
required,
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
this.#questions.set(String(wireId), question);
|
|
490
|
+
this.#options.onQuestion(question);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async #request(method: string, params: unknown): Promise<unknown> {
|
|
494
|
+
if (this.#closed) throw new Error("Agent process is not running.");
|
|
495
|
+
const id = this.#nextRequestId++;
|
|
496
|
+
const { promise, resolve, reject } = Promise.withResolvers<unknown>();
|
|
497
|
+
this.#pending.set(String(id), { resolve, reject });
|
|
498
|
+
|
|
499
|
+
try {
|
|
500
|
+
this.#write({ jsonrpc: "2.0", id, method, params });
|
|
501
|
+
} catch (error) {
|
|
502
|
+
this.#pending.delete(String(id));
|
|
503
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return await promise;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
#respond(id: number | string, result: unknown): void {
|
|
510
|
+
try {
|
|
511
|
+
this.#write({ jsonrpc: "2.0", id, result });
|
|
512
|
+
} catch {
|
|
513
|
+
// The peer is gone; nothing left to answer.
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
#respondError(id: number | string, code: number, message: string): void {
|
|
518
|
+
try {
|
|
519
|
+
this.#write({ jsonrpc: "2.0", id, error: { code, message } });
|
|
520
|
+
} catch {
|
|
521
|
+
// The peer is gone; nothing left to answer.
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
#write(message: unknown): void {
|
|
526
|
+
if (this.#closed) throw new Error("Agent process is not running.");
|
|
527
|
+
this.#process.stdin.write(`${JSON.stringify(message)}\n`);
|
|
528
|
+
void this.#process.stdin.flush();
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
export function agentPrompt(adrDir: string, file: string): string {
|
|
533
|
+
const path = `${adrDir.replace(/[\\/]+$/, "")}/${file}`;
|
|
534
|
+
return [
|
|
535
|
+
`/adr-resolve ${path}`,
|
|
536
|
+
"",
|
|
537
|
+
`Resolve only ${path}.`,
|
|
538
|
+
"Do not open, read, or resolve any other record. Stop as soon as this record's `status` is no longer `proposed` or `draft`.",
|
|
539
|
+
].join("\n");
|
|
540
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { nextActivity, type AcpActivity, type ActivityStream } from "./acp";
|
|
3
|
+
|
|
4
|
+
function textUpdate(text: string, messageId?: string) {
|
|
5
|
+
return {
|
|
6
|
+
sessionUpdate: "agent_message_chunk",
|
|
7
|
+
content: { type: "text", text },
|
|
8
|
+
...(messageId === undefined ? {} : { messageId }),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function thoughtUpdate(text: string, messageId: string) {
|
|
13
|
+
return {
|
|
14
|
+
sessionUpdate: "agent_thought_chunk",
|
|
15
|
+
content: { type: "text", text },
|
|
16
|
+
messageId,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function collect(updates: unknown[]): AcpActivity[] {
|
|
21
|
+
let stream: ActivityStream | undefined;
|
|
22
|
+
const activities: AcpActivity[] = [];
|
|
23
|
+
for (const update of updates) {
|
|
24
|
+
const next = nextActivity(update, stream);
|
|
25
|
+
if (next === undefined) continue;
|
|
26
|
+
stream = next.stream;
|
|
27
|
+
activities.push(next.activity);
|
|
28
|
+
}
|
|
29
|
+
return activities;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("activity coalescing", () => {
|
|
33
|
+
test("joins the deltas of one message and starts a line per message", () => {
|
|
34
|
+
expect(
|
|
35
|
+
collect([
|
|
36
|
+
textUpdate("The browser", "message-1"),
|
|
37
|
+
textUpdate(" interface shows", "message-1"),
|
|
38
|
+
textUpdate(" the stream.", "message-1"),
|
|
39
|
+
textUpdate("Next message.", "message-2"),
|
|
40
|
+
]),
|
|
41
|
+
).toEqual([
|
|
42
|
+
{ text: "The browser", append: false },
|
|
43
|
+
{ text: " interface shows", append: true },
|
|
44
|
+
{ text: " the stream.", append: true },
|
|
45
|
+
{ text: "Next message.", append: false },
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("does not join a reply onto the thought stream of the same message", () => {
|
|
50
|
+
expect(
|
|
51
|
+
collect([
|
|
52
|
+
thoughtUpdate("Considering the record.", "message-1"),
|
|
53
|
+
textUpdate("Resolving it.", "message-1"),
|
|
54
|
+
]),
|
|
55
|
+
).toEqual([
|
|
56
|
+
{ text: "Considering the record.", append: false },
|
|
57
|
+
{ text: "Resolving it.", append: false },
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("starts a new line after a tool call even when the message id repeats", () => {
|
|
62
|
+
expect(
|
|
63
|
+
collect([
|
|
64
|
+
textUpdate("Working", "message-1"),
|
|
65
|
+
{ sessionUpdate: "tool_call", title: "Reading 0001-example.md" },
|
|
66
|
+
textUpdate(" more", "message-1"),
|
|
67
|
+
]),
|
|
68
|
+
).toEqual([
|
|
69
|
+
{ text: "Working", append: false },
|
|
70
|
+
{ text: "Reading 0001-example.md", append: false },
|
|
71
|
+
{ text: " more", append: false },
|
|
72
|
+
]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("treats text without a message id as complete lines", () => {
|
|
76
|
+
expect(collect([textUpdate("Inspecting"), textUpdate("Updated")])).toEqual([
|
|
77
|
+
{ text: "Inspecting", append: false },
|
|
78
|
+
{ text: "Updated", append: false },
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("reports a plan as one line and drops empty updates", () => {
|
|
83
|
+
expect(
|
|
84
|
+
collect([
|
|
85
|
+
textUpdate("Working", "message-1"),
|
|
86
|
+
{ sessionUpdate: "plan", entries: [{ content: "Read" }, { content: "Write" }] },
|
|
87
|
+
textUpdate("", "message-2"),
|
|
88
|
+
textUpdate("After the plan", "message-2"),
|
|
89
|
+
]),
|
|
90
|
+
).toEqual([
|
|
91
|
+
{ text: "Working", append: false },
|
|
92
|
+
{ text: "Read\nWrite", append: false },
|
|
93
|
+
{ text: "After the plan", append: false },
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one activity-delta rule both detached workers' dashboards use. A whole
|
|
3
|
+
* replay replaces the log, a grown log appends, and a trimmed or rewritten log
|
|
4
|
+
* is replayed; a streamed chunk continues the last line only while it is a
|
|
5
|
+
* prefix extension of it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface AcpLikeActivity {
|
|
9
|
+
text: string;
|
|
10
|
+
append: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type ActivityDelta =
|
|
14
|
+
| { replay: true; entries: AcpLikeActivity[] }
|
|
15
|
+
| { replay: false; entries: AcpLikeActivity[] };
|
|
16
|
+
|
|
17
|
+
export function activityDelta(previous: string[], activity: string[]): ActivityDelta {
|
|
18
|
+
const replay = (): ActivityDelta => ({
|
|
19
|
+
replay: true,
|
|
20
|
+
entries: activity.map((text) => ({ text, append: false })),
|
|
21
|
+
});
|
|
22
|
+
if (activity.length === 0) return { replay: false, entries: [] };
|
|
23
|
+
if (previous.length === 0) return replay();
|
|
24
|
+
if (activity.length < previous.length) return replay();
|
|
25
|
+
|
|
26
|
+
let prefix = 0;
|
|
27
|
+
while (prefix < previous.length && prefix < activity.length && previous[prefix] === activity[prefix]) {
|
|
28
|
+
prefix += 1;
|
|
29
|
+
}
|
|
30
|
+
if (prefix === previous.length) {
|
|
31
|
+
return {
|
|
32
|
+
replay: false,
|
|
33
|
+
entries: activity.slice(prefix).map((text) => ({ text, append: false })),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (prefix === previous.length - 1) {
|
|
37
|
+
const before = previous[prefix] ?? "";
|
|
38
|
+
const after = activity[prefix] ?? "";
|
|
39
|
+
if (after.startsWith(before)) {
|
|
40
|
+
const entries: AcpLikeActivity[] = [];
|
|
41
|
+
const extension = after.slice(before.length);
|
|
42
|
+
if (extension.length > 0) entries.push({ text: extension, append: true });
|
|
43
|
+
for (const text of activity.slice(previous.length)) entries.push({ text, append: false });
|
|
44
|
+
return { replay: false, entries };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return replay();
|
|
48
|
+
}
|