@app-vitals/agent-runtime 0.0.1 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +265 -0
- package/agent-account-status-ref.ts +53 -0
- package/agent-account-status-ref.unit.test.ts +24 -0
- package/agent-extension.ts +144 -0
- package/agent-extension.unit.test.ts +139 -0
- package/agent-repos-ref.ts +63 -0
- package/agent-repos-ref.unit.test.ts +84 -0
- package/agent-slack-membership-ref.ts +94 -0
- package/agent-slack-membership-ref.unit.test.ts +201 -0
- package/agent-trial-expiry-ref.ts +63 -0
- package/agent-trial-expiry-ref.unit.test.ts +84 -0
- package/anthropic-federation-guard.ts +72 -0
- package/anthropic-federation-guard.unit.test.ts +127 -0
- package/base-image-contract.smoke.test.ts +149 -0
- package/boot.ts +845 -0
- package/browser.integration.test.ts +52 -0
- package/browser.ts +34 -0
- package/chat-poller.integration.test.ts +109 -0
- package/chat-poller.ts +401 -0
- package/chat-poller.unit.test.ts +1581 -0
- package/chat-token-reporter.integration.test.ts +282 -0
- package/chat-token-reporter.ts +136 -0
- package/claude-version.ts +55 -0
- package/claude-version.unit.test.ts +73 -0
- package/claude.ts +1054 -0
- package/claude.unit.test.ts +3042 -0
- package/cleanup-after-days-ref.ts +83 -0
- package/cleanup-after-days-ref.unit.test.ts +89 -0
- package/cleanup-merged-worktrees-ref.ts +75 -0
- package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
- package/cli-args.ts +36 -0
- package/cli-args.unit.test.ts +94 -0
- package/clock.ts +47 -0
- package/config.ts +99 -0
- package/config.unit.test.ts +356 -0
- package/context-stamp.ts +189 -0
- package/context-stamp.unit.test.ts +159 -0
- package/cron-failure-reporter.ts +107 -0
- package/cron-failure-reporter.unit.test.ts +244 -0
- package/cron-handler.integration.test.ts +614 -0
- package/cron-handler.smoke.test.ts +1371 -0
- package/cron-handler.ts +750 -0
- package/cron-handler.unit.test.ts +1177 -0
- package/cron-run-reporter.integration.test.ts +806 -0
- package/cron-run-reporter.ts +398 -0
- package/crypto.ts +73 -0
- package/cutover-validate.integration.test.ts +245 -0
- package/cutover-validate.ts +84 -0
- package/entrypoint-main.ts +79 -0
- package/entrypoint.integration.test.ts +315 -0
- package/entrypoint.ts +154 -0
- package/errors.ts +61 -0
- package/fixtures/chat-service/attachment.json +15 -0
- package/fixtures/chat-service/messages.json +86 -0
- package/fixtures/chat-service/threads.json +50 -0
- package/fixtures/github-installations/installations.json +1231 -0
- package/fixtures/slack-provision-cassette.json +6 -0
- package/fixtures/specialties/fixture/manifest.yaml +35 -0
- package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
- package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
- package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
- package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
- package/fixtures/stream-json/clean-multi-turn.ts +209 -0
- package/fixtures/stream-json/progress-phases.ts +203 -0
- package/fixtures/stream-json/skill-attribution.ts +251 -0
- package/fixtures/stream-json/truncated-no-result.ts +84 -0
- package/format.ts +283 -0
- package/format.unit.test.ts +558 -0
- package/gh-helpers.ts +139 -0
- package/gh-helpers.unit.test.ts +253 -0
- package/gh-token-files.integration.test.ts +143 -0
- package/gh-token-files.ts +92 -0
- package/gh-token-files.unit.test.ts +63 -0
- package/github-app-auth.integration.test.ts +419 -0
- package/github-app-auth.ts +193 -0
- package/github-auth-deps.ts +128 -0
- package/github-auth-startup.ts +231 -0
- package/github-auth-startup.unit.test.ts +325 -0
- package/github-installations-manager.integration.test.ts +323 -0
- package/github-installations-reporter.integration.test.ts +124 -0
- package/github-installations-reporter.ts +150 -0
- package/github-installations-reporter.unit.test.ts +166 -0
- package/github-installations.integration.test.ts +88 -0
- package/github-installations.ts +462 -0
- package/github-installations.unit.test.ts +98 -0
- package/health.smoke.test.ts +244 -0
- package/health.ts +193 -0
- package/health.unit.test.ts +40 -0
- package/http-chat-service-client.integration.test.ts +345 -0
- package/http-chat-service-client.ts +287 -0
- package/http-chat-service-client.unit.test.ts +500 -0
- package/log-prefix.ts +12 -0
- package/log-prefix.unit.test.ts +24 -0
- package/markers.ts +178 -0
- package/markers.unit.test.ts +374 -0
- package/package.json +50 -4
- package/piper-voice.ts +116 -0
- package/piper-voice.unit.test.ts +124 -0
- package/pricing.ts +7 -0
- package/pricing.unit.test.ts +149 -0
- package/process-tree-kill.integration.test.ts +133 -0
- package/process-tree-kill.ts +325 -0
- package/process-tree-kill.unit.test.ts +265 -0
- package/progress-milestones.ts +86 -0
- package/progress-milestones.unit.test.ts +188 -0
- package/run-telemetry.ts +241 -0
- package/run-telemetry.unit.test.ts +235 -0
- package/sessions.integration.test.ts +273 -0
- package/sessions.ts +191 -0
- package/sessions.unit.test.ts +94 -0
- package/setup-github-auth.integration.test.ts +858 -0
- package/setup-github-auth.ts +297 -0
- package/setup.fixture.integration.test.ts +109 -0
- package/setup.integration.test.ts +1495 -0
- package/setup.ts +853 -0
- package/setup.unit.test.ts +286 -0
- package/shipwright-config-client.ts +36 -0
- package/shipwright-runtime-client.integration.test.ts +336 -0
- package/shipwright-runtime-client.ts +118 -0
- package/slack-account-status.unit.test.ts +251 -0
- package/slack-bolt-client.integration.test.ts +76 -0
- package/slack-chat-conversation.integration.test.ts +333 -0
- package/slack-progress.ts +510 -0
- package/slack-progress.unit.test.ts +806 -0
- package/slack-socket-mode-ping.integration.test.ts +137 -0
- package/slack-startup.ts +170 -0
- package/slack-startup.unit.test.ts +300 -0
- package/slack-thread-status-tracker.ts +70 -0
- package/slack-thread-status-tracker.unit.test.ts +118 -0
- package/slack-trial-expiry.unit.test.ts +269 -0
- package/slack.integration.test.ts +3979 -0
- package/slack.ts +1286 -0
- package/slack.unit.test.ts +1070 -0
- package/spawn-env.ts +58 -0
- package/spawn-env.unit.test.ts +96 -0
- package/startup-dm.integration.test.ts +156 -0
- package/startup-dm.ts +29 -0
- package/sync-config-clone.ts +238 -0
- package/sync-config-clone.unit.test.ts +429 -0
- package/test-env.ts +27 -0
- package/users.ts +90 -0
- package/users.unit.test.ts +108 -0
- package/voice.integration.test.ts +669 -0
- package/voice.ts +315 -0
- package/voice.unit.test.ts +105 -0
- package/workspace/BOOTSTRAP.md.template +43 -0
- package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
- package/workspace/IDENTITY.md.template +8 -0
- package/workspace/RUNTIME.md.template +193 -0
- package/workspace/RUNTIME.md.template.content.test.ts +75 -0
- package/workspace/SOUL.md.template +45 -0
- package/workspace/SPECIALTY.md.template +37 -0
- package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
- package/workspace/VOICE.md.template +8 -0
- package/workspace/mise.toml.template +12 -0
- package/workspace/requirements.txt.template +1 -0
- package/workspace-paths.ts +37 -0
- package/worktree-reaper.ts +244 -0
- package/worktree-reaper.unit.test.ts +380 -0
- 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,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@app-vitals/agent-runtime",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/app-vitals/agent.git",
|
|
7
|
+
"directory": "runtime"
|
|
8
|
+
},
|
|
5
9
|
"license": "MIT",
|
|
6
|
-
"
|
|
7
|
-
|
|
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
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@app-vitals/agent-admin": "0.1.0",
|
|
44
|
+
"@app-vitals/agent-lib": "0.1.0",
|
|
45
|
+
"@octokit/auth-app": "^8.2.0",
|
|
46
|
+
"@sentry/bun": "^10.63.0",
|
|
47
|
+
"@slack/bolt": "^5.0.0",
|
|
48
|
+
"@slack/web-api": "^8.1.0",
|
|
49
|
+
"hono": "^4.13.1",
|
|
50
|
+
"node-cron": "^4.6.0",
|
|
51
|
+
"openapi-fetch": "^0.17.0",
|
|
52
|
+
"playwright": "1.64.1"
|
|
53
|
+
}
|
|
8
54
|
}
|