@app-vitals/agent-runtime 0.0.1 → 0.1.0

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.
Files changed (160) hide show
  1. package/Dockerfile +265 -0
  2. package/agent-account-status-ref.ts +53 -0
  3. package/agent-account-status-ref.unit.test.ts +24 -0
  4. package/agent-extension.ts +144 -0
  5. package/agent-extension.unit.test.ts +139 -0
  6. package/agent-repos-ref.ts +63 -0
  7. package/agent-repos-ref.unit.test.ts +84 -0
  8. package/agent-slack-membership-ref.ts +94 -0
  9. package/agent-slack-membership-ref.unit.test.ts +201 -0
  10. package/agent-trial-expiry-ref.ts +63 -0
  11. package/agent-trial-expiry-ref.unit.test.ts +84 -0
  12. package/anthropic-federation-guard.ts +72 -0
  13. package/anthropic-federation-guard.unit.test.ts +127 -0
  14. package/base-image-contract.smoke.test.ts +149 -0
  15. package/boot.ts +845 -0
  16. package/browser.integration.test.ts +52 -0
  17. package/browser.ts +34 -0
  18. package/chat-poller.integration.test.ts +109 -0
  19. package/chat-poller.ts +401 -0
  20. package/chat-poller.unit.test.ts +1581 -0
  21. package/chat-token-reporter.integration.test.ts +282 -0
  22. package/chat-token-reporter.ts +136 -0
  23. package/claude-version.ts +55 -0
  24. package/claude-version.unit.test.ts +73 -0
  25. package/claude.ts +1054 -0
  26. package/claude.unit.test.ts +3042 -0
  27. package/cleanup-after-days-ref.ts +83 -0
  28. package/cleanup-after-days-ref.unit.test.ts +89 -0
  29. package/cleanup-merged-worktrees-ref.ts +75 -0
  30. package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
  31. package/cli-args.ts +36 -0
  32. package/cli-args.unit.test.ts +94 -0
  33. package/clock.ts +47 -0
  34. package/config.ts +99 -0
  35. package/config.unit.test.ts +356 -0
  36. package/context-stamp.ts +189 -0
  37. package/context-stamp.unit.test.ts +159 -0
  38. package/cron-failure-reporter.ts +107 -0
  39. package/cron-failure-reporter.unit.test.ts +244 -0
  40. package/cron-handler.integration.test.ts +614 -0
  41. package/cron-handler.smoke.test.ts +1371 -0
  42. package/cron-handler.ts +750 -0
  43. package/cron-handler.unit.test.ts +1177 -0
  44. package/cron-run-reporter.integration.test.ts +806 -0
  45. package/cron-run-reporter.ts +398 -0
  46. package/crypto.ts +73 -0
  47. package/cutover-validate.integration.test.ts +245 -0
  48. package/cutover-validate.ts +84 -0
  49. package/entrypoint-main.ts +79 -0
  50. package/entrypoint.integration.test.ts +315 -0
  51. package/entrypoint.ts +154 -0
  52. package/errors.ts +61 -0
  53. package/fixtures/chat-service/attachment.json +15 -0
  54. package/fixtures/chat-service/messages.json +86 -0
  55. package/fixtures/chat-service/threads.json +50 -0
  56. package/fixtures/github-installations/installations.json +1231 -0
  57. package/fixtures/slack-provision-cassette.json +6 -0
  58. package/fixtures/specialties/fixture/manifest.yaml +35 -0
  59. package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
  60. package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
  61. package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
  62. package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
  63. package/fixtures/stream-json/clean-multi-turn.ts +209 -0
  64. package/fixtures/stream-json/progress-phases.ts +203 -0
  65. package/fixtures/stream-json/skill-attribution.ts +251 -0
  66. package/fixtures/stream-json/truncated-no-result.ts +84 -0
  67. package/format.ts +283 -0
  68. package/format.unit.test.ts +558 -0
  69. package/gh-helpers.ts +139 -0
  70. package/gh-helpers.unit.test.ts +253 -0
  71. package/gh-token-files.integration.test.ts +143 -0
  72. package/gh-token-files.ts +92 -0
  73. package/gh-token-files.unit.test.ts +63 -0
  74. package/github-app-auth.integration.test.ts +419 -0
  75. package/github-app-auth.ts +193 -0
  76. package/github-auth-deps.ts +128 -0
  77. package/github-auth-startup.ts +231 -0
  78. package/github-auth-startup.unit.test.ts +325 -0
  79. package/github-installations-manager.integration.test.ts +323 -0
  80. package/github-installations-reporter.integration.test.ts +124 -0
  81. package/github-installations-reporter.ts +150 -0
  82. package/github-installations-reporter.unit.test.ts +166 -0
  83. package/github-installations.integration.test.ts +88 -0
  84. package/github-installations.ts +462 -0
  85. package/github-installations.unit.test.ts +98 -0
  86. package/health.smoke.test.ts +244 -0
  87. package/health.ts +193 -0
  88. package/health.unit.test.ts +40 -0
  89. package/http-chat-service-client.integration.test.ts +345 -0
  90. package/http-chat-service-client.ts +287 -0
  91. package/http-chat-service-client.unit.test.ts +500 -0
  92. package/log-prefix.ts +12 -0
  93. package/log-prefix.unit.test.ts +24 -0
  94. package/markers.ts +178 -0
  95. package/markers.unit.test.ts +374 -0
  96. package/package.json +38 -4
  97. package/piper-voice.ts +116 -0
  98. package/piper-voice.unit.test.ts +124 -0
  99. package/pricing.ts +7 -0
  100. package/pricing.unit.test.ts +149 -0
  101. package/process-tree-kill.integration.test.ts +133 -0
  102. package/process-tree-kill.ts +325 -0
  103. package/process-tree-kill.unit.test.ts +265 -0
  104. package/progress-milestones.ts +86 -0
  105. package/progress-milestones.unit.test.ts +188 -0
  106. package/run-telemetry.ts +241 -0
  107. package/run-telemetry.unit.test.ts +235 -0
  108. package/sessions.integration.test.ts +273 -0
  109. package/sessions.ts +191 -0
  110. package/sessions.unit.test.ts +94 -0
  111. package/setup-github-auth.integration.test.ts +858 -0
  112. package/setup-github-auth.ts +297 -0
  113. package/setup.fixture.integration.test.ts +109 -0
  114. package/setup.integration.test.ts +1495 -0
  115. package/setup.ts +853 -0
  116. package/setup.unit.test.ts +286 -0
  117. package/shipwright-config-client.ts +36 -0
  118. package/shipwright-runtime-client.integration.test.ts +336 -0
  119. package/shipwright-runtime-client.ts +118 -0
  120. package/slack-account-status.unit.test.ts +251 -0
  121. package/slack-bolt-client.integration.test.ts +76 -0
  122. package/slack-chat-conversation.integration.test.ts +333 -0
  123. package/slack-progress.ts +510 -0
  124. package/slack-progress.unit.test.ts +806 -0
  125. package/slack-socket-mode-ping.integration.test.ts +137 -0
  126. package/slack-startup.ts +170 -0
  127. package/slack-startup.unit.test.ts +300 -0
  128. package/slack-thread-status-tracker.ts +70 -0
  129. package/slack-thread-status-tracker.unit.test.ts +118 -0
  130. package/slack-trial-expiry.unit.test.ts +269 -0
  131. package/slack.integration.test.ts +3979 -0
  132. package/slack.ts +1286 -0
  133. package/slack.unit.test.ts +1070 -0
  134. package/spawn-env.ts +58 -0
  135. package/spawn-env.unit.test.ts +96 -0
  136. package/startup-dm.integration.test.ts +156 -0
  137. package/startup-dm.ts +29 -0
  138. package/sync-config-clone.ts +238 -0
  139. package/sync-config-clone.unit.test.ts +429 -0
  140. package/test-env.ts +27 -0
  141. package/users.ts +90 -0
  142. package/users.unit.test.ts +108 -0
  143. package/voice.integration.test.ts +669 -0
  144. package/voice.ts +315 -0
  145. package/voice.unit.test.ts +105 -0
  146. package/workspace/BOOTSTRAP.md.template +43 -0
  147. package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
  148. package/workspace/IDENTITY.md.template +8 -0
  149. package/workspace/RUNTIME.md.template +193 -0
  150. package/workspace/RUNTIME.md.template.content.test.ts +75 -0
  151. package/workspace/SOUL.md.template +45 -0
  152. package/workspace/SPECIALTY.md.template +37 -0
  153. package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
  154. package/workspace/VOICE.md.template +8 -0
  155. package/workspace/mise.toml.template +12 -0
  156. package/workspace/requirements.txt.template +1 -0
  157. package/workspace-paths.ts +37 -0
  158. package/worktree-reaper.ts +244 -0
  159. package/worktree-reaper.unit.test.ts +380 -0
  160. package/README.md +0 -3
package/markers.ts ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Response marker parsing for agent replies.
3
+ *
4
+ * Claude can embed special markers in its response text to trigger side-effect
5
+ * actions. Markers are stripped from the visible message before posting.
6
+ *
7
+ * Supported markers:
8
+ * [silent] — skip posting
9
+ * [upload:/path/to/file] — upload a file
10
+ * [speak:text] — synthesize speech and upload audio file
11
+ * [react:emoji1,emoji2] — add emoji reactions
12
+ * [plan:url] — post a "View plan" link to the channel/thread
13
+ * [skip-reason:text] — tag a machine-readable reason for a silent
14
+ * dispatch; parsed independently of [silent]
15
+ * (does not require it to also be present),
16
+ * but only meaningful when it is — the
17
+ * loop orchestrator uses it as the
18
+ * skipRun() reason on a [silent] dispatch,
19
+ * falling back to "command:no-work" when
20
+ * absent (see agent/src/coding/loop-orchestrator.ts)
21
+ *
22
+ * Malformed markers are logged and left in the cleaned text (not crashed on).
23
+ */
24
+
25
+ interface SilentMarker {
26
+ type: "silent";
27
+ }
28
+
29
+ interface UploadMarker {
30
+ type: "upload";
31
+ path: string;
32
+ }
33
+
34
+ interface SpeakMarker {
35
+ type: "speak";
36
+ text: string;
37
+ }
38
+
39
+ interface ReactMarker {
40
+ type: "react";
41
+ emojis: string[];
42
+ }
43
+
44
+ interface PlanMarker {
45
+ type: "plan";
46
+ url: string;
47
+ }
48
+
49
+ interface SkipReasonMarker {
50
+ type: "skip-reason";
51
+ reason: string;
52
+ }
53
+
54
+ export type Marker =
55
+ | SilentMarker
56
+ | UploadMarker
57
+ | SpeakMarker
58
+ | ReactMarker
59
+ | PlanMarker
60
+ | SkipReasonMarker;
61
+
62
+ interface ParseMarkersResult {
63
+ cleaned: string;
64
+ markers: Marker[];
65
+ }
66
+
67
+ // Regex patterns — greedy non-nested matching.
68
+ // [silent] must appear at the end of the response so meta-discussion of the
69
+ // marker (e.g. "Use [silent] to skip posting") doesn't accidentally silence it.
70
+ const SILENT_REGEX = /\[silent\]\s*$/i;
71
+ const UPLOAD_REGEX = /\[upload:([^\]]+)\]/gi;
72
+ const SPEAK_REGEX = /\[speak:([\s\S]*?)\]/gi;
73
+ const REACT_REGEX = /\[react:([^\]]+)\]/gi;
74
+ const PLAN_REGEX = /\[plan:([^\]]+)\]/gi;
75
+ const SKIP_REASON_REGEX = /\[skip-reason:([^\]]+)\]/gi;
76
+
77
+ /**
78
+ * Parse all response markers from text.
79
+ * Returns the cleaned text (markers stripped) and an ordered list of markers found.
80
+ * Malformed markers are logged and left in the cleaned text.
81
+ */
82
+ export function parseMarkers(text: string): ParseMarkersResult {
83
+ const markers: Marker[] = [];
84
+ let cleaned = text;
85
+
86
+ // [skip-reason:text] — stripped before the [silent] end-anchor check below
87
+ // so [silent] is still recognized regardless of whether [skip-reason:...]
88
+ // appears before or after it in the raw text (DBV-1.1).
89
+ cleaned = cleaned.replace(SKIP_REASON_REGEX, (_match, reasonRaw: string) => {
90
+ const reason = reasonRaw.trim();
91
+ if (!reason) {
92
+ console.warn(
93
+ "[markers] malformed [skip-reason:] marker — empty reason, leaving in text:",
94
+ _match,
95
+ );
96
+ return _match;
97
+ }
98
+ markers.push({ type: "skip-reason", reason });
99
+ return "";
100
+ });
101
+ SKIP_REASON_REGEX.lastIndex = 0;
102
+ cleaned = cleaned.trim();
103
+
104
+ // [silent]
105
+ if (SILENT_REGEX.test(cleaned)) {
106
+ markers.push({ type: "silent" });
107
+ cleaned = cleaned.replace(SILENT_REGEX, "").trim();
108
+ }
109
+
110
+ // [upload:/path]
111
+ cleaned = cleaned.replace(UPLOAD_REGEX, (_match, pathRaw: string) => {
112
+ const path = pathRaw.trim();
113
+ if (!path) {
114
+ console.warn(
115
+ "[markers] malformed [upload:] marker — empty path, leaving in text:",
116
+ _match,
117
+ );
118
+ return _match;
119
+ }
120
+ markers.push({ type: "upload", path });
121
+ return "";
122
+ });
123
+ UPLOAD_REGEX.lastIndex = 0;
124
+ cleaned = cleaned.trim();
125
+
126
+ // [speak:text]
127
+ cleaned = cleaned.replace(SPEAK_REGEX, (_match, textRaw: string) => {
128
+ const text = textRaw.trim();
129
+ if (!text) {
130
+ console.warn(
131
+ "[markers] malformed [speak:] marker — empty text, leaving in text:",
132
+ _match,
133
+ );
134
+ return _match;
135
+ }
136
+ markers.push({ type: "speak", text });
137
+ return "";
138
+ });
139
+ SPEAK_REGEX.lastIndex = 0;
140
+ cleaned = cleaned.trim();
141
+
142
+ // [react:emoji1,emoji2,...]
143
+ cleaned = cleaned.replace(REACT_REGEX, (_match, raw: string) => {
144
+ const emojis = raw
145
+ .split(",")
146
+ .map((e) => e.trim())
147
+ .filter(Boolean);
148
+ if (emojis.length === 0) {
149
+ console.warn(
150
+ "[markers] malformed [react:] marker — empty emoji list, leaving in text:",
151
+ _match,
152
+ );
153
+ return _match;
154
+ }
155
+ markers.push({ type: "react", emojis });
156
+ return "";
157
+ });
158
+ REACT_REGEX.lastIndex = 0;
159
+ cleaned = cleaned.trim();
160
+
161
+ // [plan:url]
162
+ cleaned = cleaned.replace(PLAN_REGEX, (_match, urlRaw: string) => {
163
+ const url = urlRaw.trim();
164
+ if (!url) {
165
+ console.warn(
166
+ "[markers] malformed [plan:] marker — empty url, leaving in text:",
167
+ _match,
168
+ );
169
+ return _match;
170
+ }
171
+ markers.push({ type: "plan", url });
172
+ return "";
173
+ });
174
+ PLAN_REGEX.lastIndex = 0;
175
+ cleaned = cleaned.trim();
176
+
177
+ return { cleaned, markers };
178
+ }
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Unit tests for parseMarkers() in agent/src/runtime/markers.ts
3
+ *
4
+ * Pure function — no side effects, no mocks needed.
5
+ */
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { parseMarkers } from "./markers.ts";
9
+
10
+ // ─── [silent] ─────────────────────────────────────────────────────────────────
11
+
12
+ describe("[silent] marker", () => {
13
+ test("detects [silent] and returns silent marker", () => {
14
+ const { markers } = parseMarkers("[silent]");
15
+ expect(markers).toHaveLength(1);
16
+ expect(markers[0].type).toBe("silent");
17
+ });
18
+
19
+ test("strips [silent] from cleaned text", () => {
20
+ const { cleaned } = parseMarkers("[silent]");
21
+ expect(cleaned).toBe("");
22
+ });
23
+
24
+ test("[silent] in middle does NOT trigger silent marker", () => {
25
+ const { cleaned, markers } = parseMarkers("before [silent] after");
26
+ expect(markers.some((m) => m.type === "silent")).toBe(false);
27
+ expect(cleaned).toBe("before [silent] after");
28
+ });
29
+
30
+ test("[silent] at end triggers silent marker and strips it", () => {
31
+ const { cleaned, markers } = parseMarkers("All done.\n[silent]");
32
+ expect(markers.some((m) => m.type === "silent")).toBe(true);
33
+ expect(cleaned).toBe("All done.");
34
+ });
35
+
36
+ test("[silent] at start without trailing position is NOT silent", () => {
37
+ const { markers } = parseMarkers("[silent] but also text");
38
+ expect(markers.some((m) => m.type === "silent")).toBe(false);
39
+ });
40
+
41
+ test("[silent] with trailing whitespace still triggers silent", () => {
42
+ const { markers } = parseMarkers("[silent] \n ");
43
+ expect(markers.some((m) => m.type === "silent")).toBe(true);
44
+ });
45
+
46
+ test("is case-insensitive", () => {
47
+ const { markers } = parseMarkers("[SILENT]");
48
+ expect(markers.some((m) => m.type === "silent")).toBe(true);
49
+ });
50
+
51
+ test("text without [silent] has no silent marker", () => {
52
+ const { markers } = parseMarkers("Hello world");
53
+ expect(markers.some((m) => m.type === "silent")).toBe(false);
54
+ });
55
+ });
56
+
57
+ // ─── [upload:...] ─────────────────────────────────────────────────────────────
58
+
59
+ describe("[upload:...] marker", () => {
60
+ test("parses a file path", () => {
61
+ const { markers, cleaned } = parseMarkers(
62
+ "See attached [upload:/tmp/report.pdf]",
63
+ );
64
+ const uploadMarker = markers.find((m) => m.type === "upload");
65
+ expect(uploadMarker).toBeDefined();
66
+ expect((uploadMarker as { type: "upload"; path: string }).path).toBe(
67
+ "/tmp/report.pdf",
68
+ );
69
+ expect(cleaned).toBe("See attached");
70
+ });
71
+
72
+ test("parses multiple upload markers", () => {
73
+ const { markers } = parseMarkers("[upload:/tmp/a.pdf][upload:/tmp/b.png]");
74
+ const uploads = markers.filter((m) => m.type === "upload") as {
75
+ type: "upload";
76
+ path: string;
77
+ }[];
78
+ expect(uploads).toHaveLength(2);
79
+ expect(uploads[0].path).toBe("/tmp/a.pdf");
80
+ expect(uploads[1].path).toBe("/tmp/b.png");
81
+ });
82
+
83
+ test("strips upload marker from cleaned text", () => {
84
+ const { cleaned } = parseMarkers("Done [upload:/tmp/out.csv]");
85
+ expect(cleaned).not.toContain("[upload:");
86
+ });
87
+
88
+ test("malformed upload with empty path is left in text", () => {
89
+ const { cleaned, markers } = parseMarkers("[upload:]");
90
+ expect(cleaned).toContain("[upload:]");
91
+ expect(markers.some((m) => m.type === "upload")).toBe(false);
92
+ });
93
+
94
+ test("malformed upload with whitespace-only path is left in text", () => {
95
+ const { cleaned, markers } = parseMarkers("[upload: ]");
96
+ expect(cleaned).toContain("[upload: ]");
97
+ expect(markers.some((m) => m.type === "upload")).toBe(false);
98
+ });
99
+ });
100
+
101
+ // ─── [plan:url] ───────────────────────────────────────────────────────────────
102
+
103
+ describe("[plan:url] marker", () => {
104
+ test("detects [plan:url] and returns plan marker with url", () => {
105
+ const { markers } = parseMarkers(
106
+ "Plan ready [plan:https://example.com/p/abc]",
107
+ );
108
+ const planMarker = markers.find((m) => m.type === "plan");
109
+ expect(planMarker).toBeDefined();
110
+ if (planMarker?.type === "plan") {
111
+ expect(planMarker.url).toBe("https://example.com/p/abc");
112
+ }
113
+ });
114
+
115
+ test("strips [plan:url] from cleaned text", () => {
116
+ const { cleaned } = parseMarkers("Done [plan:https://example.com/p/abc]");
117
+ expect(cleaned).toBe("Done");
118
+ expect(cleaned).not.toContain("[plan:");
119
+ });
120
+
121
+ test("malformed [plan:] with empty url is left in text and yields no marker", () => {
122
+ const { cleaned, markers } = parseMarkers("[plan:]");
123
+ expect(markers.some((m) => m.type === "plan")).toBe(false);
124
+ expect(cleaned).toContain("[plan:]");
125
+ });
126
+
127
+ test("[plan:url] mid-text is still parsed (markers can appear anywhere)", () => {
128
+ const { cleaned, markers } = parseMarkers(
129
+ "before [plan:https://example.com/p/xyz] after",
130
+ );
131
+ const planMarker = markers.find((m) => m.type === "plan");
132
+ expect(planMarker).toBeDefined();
133
+ if (planMarker?.type === "plan") {
134
+ expect(planMarker.url).toBe("https://example.com/p/xyz");
135
+ }
136
+ expect(cleaned).toBe("before after");
137
+ });
138
+ });
139
+
140
+ // ─── Multiple markers ─────────────────────────────────────────────────────────
141
+
142
+ describe("multiple markers in one response", () => {
143
+ test("parses upload + speak together", () => {
144
+ const { markers, cleaned } = parseMarkers(
145
+ "Here is the report [upload:/tmp/report.pdf][speak:report ready]",
146
+ );
147
+ expect(markers.some((m) => m.type === "upload")).toBe(true);
148
+ expect(markers.some((m) => m.type === "speak")).toBe(true);
149
+ expect(cleaned).toBe("Here is the report");
150
+ });
151
+
152
+ test("silent with other markers — silent must be at end", () => {
153
+ const { markers } = parseMarkers("[upload:/tmp/out.txt][silent]");
154
+ expect(markers.some((m) => m.type === "silent")).toBe(true);
155
+ expect(markers.some((m) => m.type === "upload")).toBe(true);
156
+ });
157
+
158
+ test("no text remaining when all content is markers", () => {
159
+ const { cleaned } = parseMarkers("[silent]");
160
+ expect(cleaned).toBe("");
161
+ });
162
+ });
163
+
164
+ // ─── No markers ───────────────────────────────────────────────────────────────
165
+
166
+ describe("no markers", () => {
167
+ test("returns unchanged text when no markers present", () => {
168
+ const input = "Just a plain response with no markers.";
169
+ const { cleaned, markers } = parseMarkers(input);
170
+ expect(cleaned).toBe(input);
171
+ expect(markers).toHaveLength(0);
172
+ });
173
+
174
+ test("returns empty string for empty input", () => {
175
+ const { cleaned, markers } = parseMarkers("");
176
+ expect(cleaned).toBe("");
177
+ expect(markers).toHaveLength(0);
178
+ });
179
+ });
180
+
181
+ // ─── [speak:...] ──────────────────────────────────────────────────────────────
182
+
183
+ describe("[speak:text] marker", () => {
184
+ test("parses [speak:text] and returns speak marker", () => {
185
+ const { markers } = parseMarkers("[speak:Hello there]");
186
+ expect(markers).toHaveLength(1);
187
+ expect(markers[0].type).toBe("speak");
188
+ if (markers[0].type === "speak") {
189
+ expect(markers[0].text).toBe("Hello there");
190
+ }
191
+ });
192
+
193
+ test("strips [speak:text] from cleaned text", () => {
194
+ const { cleaned } = parseMarkers("Response text [speak:Hello there]");
195
+ expect(cleaned).toBe("Response text");
196
+ });
197
+
198
+ test("handles multi-word speak text", () => {
199
+ const { markers } = parseMarkers("[speak:The answer is 42, my friend]");
200
+ expect(markers[0].type).toBe("speak");
201
+ if (markers[0].type === "speak") {
202
+ expect(markers[0].text).toBe("The answer is 42, my friend");
203
+ }
204
+ });
205
+
206
+ test("malformed [speak:] with empty text — left in cleaned text", () => {
207
+ const { cleaned, markers } = parseMarkers("[speak:]");
208
+ expect(markers.some((m) => m.type === "speak")).toBe(false);
209
+ expect(cleaned).toContain("[speak:]");
210
+ });
211
+
212
+ test("speak with surrounding text preserves other content", () => {
213
+ const { cleaned, markers } = parseMarkers(
214
+ "Here is my answer. [speak:Here is my answer]",
215
+ );
216
+ expect(cleaned).toBe("Here is my answer.");
217
+ expect(markers.some((m) => m.type === "speak")).toBe(true);
218
+ });
219
+ });
220
+
221
+ // ─── [react:...] ──────────────────────────────────────────────────────────────
222
+
223
+ describe("[react:emoji] marker", () => {
224
+ test("parses single emoji and returns react marker", () => {
225
+ const { markers } = parseMarkers("Nice work! [react:thumbsup]");
226
+ const reactMarker = markers.find((m) => m.type === "react");
227
+ expect(reactMarker).toBeDefined();
228
+ if (reactMarker?.type === "react") {
229
+ expect(reactMarker.emojis).toEqual(["thumbsup"]);
230
+ }
231
+ });
232
+
233
+ test("strips [react:emoji] from cleaned text", () => {
234
+ const { cleaned } = parseMarkers("Done [react:white_check_mark]");
235
+ expect(cleaned).toBe("Done");
236
+ expect(cleaned).not.toContain("[react:");
237
+ });
238
+
239
+ test("parses comma-separated emojis", () => {
240
+ const { markers } = parseMarkers("[react:thumbsup,tada]");
241
+ const reactMarker = markers.find((m) => m.type === "react");
242
+ expect(reactMarker).toBeDefined();
243
+ if (reactMarker?.type === "react") {
244
+ expect(reactMarker.emojis).toEqual(["thumbsup", "tada"]);
245
+ }
246
+ });
247
+
248
+ test("parses three emojis", () => {
249
+ const { markers } = parseMarkers("All good [react:thumbsup,tada,rocket]");
250
+ const reactMarker = markers.find((m) => m.type === "react");
251
+ if (reactMarker?.type === "react") {
252
+ expect(reactMarker.emojis).toEqual(["thumbsup", "tada", "rocket"]);
253
+ }
254
+ });
255
+
256
+ test("trims whitespace from emoji names", () => {
257
+ const { markers } = parseMarkers("[react: thumbsup , tada ]");
258
+ const reactMarker = markers.find((m) => m.type === "react");
259
+ if (reactMarker?.type === "react") {
260
+ expect(reactMarker.emojis).toEqual(["thumbsup", "tada"]);
261
+ }
262
+ });
263
+
264
+ test("malformed [react:] with empty content is left in text", () => {
265
+ const { cleaned, markers } = parseMarkers("[react:]");
266
+ expect(markers.some((m) => m.type === "react")).toBe(false);
267
+ expect(cleaned).toContain("[react:]");
268
+ });
269
+
270
+ test("react marker coexists with other markers", () => {
271
+ const { markers, cleaned } = parseMarkers("Done [react:thumbsup][silent]");
272
+ expect(markers.some((m) => m.type === "react")).toBe(true);
273
+ expect(markers.some((m) => m.type === "silent")).toBe(true);
274
+ expect(cleaned).toBe("Done");
275
+ });
276
+ });
277
+
278
+ // ─── [skip-reason:text] ────────────────────────────────────────────────────
279
+
280
+ describe("[skip-reason:text] marker", () => {
281
+ test("parses [skip-reason:text] and returns a skip-reason marker with the text", () => {
282
+ const { markers } = parseMarkers(
283
+ "Nothing to do.\n[silent]\n[skip-reason:deploy:bundle-incomplete:feat/x]",
284
+ );
285
+ const marker = markers.find((m) => m.type === "skip-reason");
286
+ expect(marker).toBeDefined();
287
+ if (marker?.type === "skip-reason") {
288
+ expect(marker.reason).toBe("deploy:bundle-incomplete:feat/x");
289
+ }
290
+ });
291
+
292
+ test("strips [skip-reason:text] from cleaned text", () => {
293
+ const { cleaned } = parseMarkers(
294
+ "Done [skip-reason:deploy:bundle-incomplete:feat/x]",
295
+ );
296
+ expect(cleaned).toBe("Done");
297
+ expect(cleaned).not.toContain("[skip-reason:");
298
+ });
299
+
300
+ test("malformed [skip-reason:] with empty text is left in cleaned text and yields no marker", () => {
301
+ const { cleaned, markers } = parseMarkers("[skip-reason:]");
302
+ expect(markers.some((m) => m.type === "skip-reason")).toBe(false);
303
+ expect(cleaned).toContain("[skip-reason:]");
304
+ });
305
+
306
+ test("malformed [skip-reason:] with whitespace-only text is left in cleaned text and yields no marker", () => {
307
+ const { cleaned, markers } = parseMarkers("[skip-reason: ]");
308
+ expect(markers.some((m) => m.type === "skip-reason")).toBe(false);
309
+ expect(cleaned).toContain("[skip-reason: ]");
310
+ });
311
+
312
+ test("parses independently of [silent] being present — no [silent] marker at all", () => {
313
+ const { markers } = parseMarkers(
314
+ "Some text [skip-reason:deploy:bundle-incomplete:feat/x]",
315
+ );
316
+ expect(markers.some((m) => m.type === "silent")).toBe(false);
317
+ const marker = markers.find((m) => m.type === "skip-reason");
318
+ expect(marker).toBeDefined();
319
+ if (marker?.type === "skip-reason") {
320
+ expect(marker.reason).toBe("deploy:bundle-incomplete:feat/x");
321
+ }
322
+ });
323
+
324
+ test("coexists with [silent] at the end of the response", () => {
325
+ const { markers, cleaned } = parseMarkers(
326
+ "Bundle gate blocked.\n[skip-reason:deploy:bundle-incomplete:feat/x]\n[silent]",
327
+ );
328
+ expect(markers.some((m) => m.type === "silent")).toBe(true);
329
+ const marker = markers.find((m) => m.type === "skip-reason");
330
+ expect(marker).toBeDefined();
331
+ if (marker?.type === "skip-reason") {
332
+ expect(marker.reason).toBe("deploy:bundle-incomplete:feat/x");
333
+ }
334
+ expect(cleaned).toBe("Bundle gate blocked.");
335
+ });
336
+
337
+ test("with multiple markers present, consumers reading markers.find(...) get the first (first-match-wins)", () => {
338
+ const { markers } = parseMarkers(
339
+ "[skip-reason:first-reason][skip-reason:second-reason]",
340
+ );
341
+ const skipReasonMarkers = markers.filter((m) => m.type === "skip-reason");
342
+ expect(skipReasonMarkers).toHaveLength(2);
343
+ const firstMatch = markers.find((m) => m.type === "skip-reason");
344
+ if (firstMatch?.type === "skip-reason") {
345
+ expect(firstMatch.reason).toBe("first-reason");
346
+ }
347
+ });
348
+
349
+ test("[silent] is still recognized when [skip-reason:...] appears AFTER it (order-independent)", () => {
350
+ const { markers, cleaned } = parseMarkers(
351
+ "Bundle gate blocked.\n[silent]\n[skip-reason:deploy:bundle-incomplete:feat/x]",
352
+ );
353
+ expect(markers.some((m) => m.type === "silent")).toBe(true);
354
+ const marker = markers.find((m) => m.type === "skip-reason");
355
+ expect(marker).toBeDefined();
356
+ if (marker?.type === "skip-reason") {
357
+ expect(marker.reason).toBe("deploy:bundle-incomplete:feat/x");
358
+ }
359
+ expect(cleaned).toBe("Bundle gate blocked.");
360
+ });
361
+
362
+ test("[silent] is recognized identically regardless of [skip-reason:...] ordering (before vs after)", () => {
363
+ const before = parseMarkers(
364
+ "Blocked.\n[skip-reason:deploy:bundle-incomplete:feat/x]\n[silent]",
365
+ );
366
+ const after = parseMarkers(
367
+ "Blocked.\n[silent]\n[skip-reason:deploy:bundle-incomplete:feat/x]",
368
+ );
369
+ expect(before.markers.some((m) => m.type === "silent")).toBe(true);
370
+ expect(after.markers.some((m) => m.type === "silent")).toBe(true);
371
+ expect(before.cleaned).toBe("Blocked.");
372
+ expect(after.cleaned).toBe("Blocked.");
373
+ });
374
+ });
package/package.json CHANGED
@@ -1,8 +1,42 @@
1
1
  {
2
2
  "name": "@app-vitals/agent-runtime",
3
- "version": "0.0.1",
4
- "description": "Placeholder reserving the package name. The real release is published from github.com/app-vitals/agent.",
3
+ "version": "0.1.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/app-vitals/agent.git",
7
+ "directory": "runtime"
8
+ },
5
9
  "license": "MIT",
6
- "repository": {"type": "git", "url": "git+https://github.com/app-vitals/agent.git"},
7
- "publishConfig": {"access": "public"}
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "type": "module",
14
+ "exports": {
15
+ "./agent-extension": "./agent-extension.ts",
16
+ "./agent-repos-ref": "./agent-repos-ref.ts",
17
+ "./anthropic-federation-guard": "./anthropic-federation-guard.ts",
18
+ "./boot": "./boot.ts",
19
+ "./browser": "./browser.ts",
20
+ "./claude": "./claude.ts",
21
+ "./cleanup-after-days-ref": "./cleanup-after-days-ref.ts",
22
+ "./cleanup-merged-worktrees-ref": "./cleanup-merged-worktrees-ref.ts",
23
+ "./cli-args": "./cli-args.ts",
24
+ "./clock": "./clock.ts",
25
+ "./context-stamp": "./context-stamp.ts",
26
+ "./cron-failure-reporter": "./cron-failure-reporter.ts",
27
+ "./cron-handler": "./cron-handler.ts",
28
+ "./cron-run-reporter": "./cron-run-reporter.ts",
29
+ "./cutover-validate": "./cutover-validate.ts",
30
+ "./entrypoint": "./entrypoint.ts",
31
+ "./gh-helpers": "./gh-helpers.ts",
32
+ "./github-auth-deps": "./github-auth-deps.ts",
33
+ "./markers": "./markers.ts",
34
+ "./process-tree-kill": "./process-tree-kill.ts",
35
+ "./run-telemetry": "./run-telemetry.ts",
36
+ "./setup": "./setup.ts",
37
+ "./setup-github-auth": "./setup-github-auth.ts",
38
+ "./shipwright-runtime-client": "./shipwright-runtime-client.ts",
39
+ "./spawn-env": "./spawn-env.ts",
40
+ "./workspace-paths": "./workspace-paths.ts"
41
+ }
8
42
  }