@app-vitals/agent-runtime 0.0.0-stage → 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.
- 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 +40 -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
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand-authored synthetic `claude -p --output-format stream-json --verbose`
|
|
3
|
+
* transcript exercising `agent/src/runtime/run-telemetry.ts` end-to-end through
|
|
4
|
+
* `_consumeStream`: first-turn baseline capture, turn/tool-call counting, and
|
|
5
|
+
* per-skill attribution with a sub-agent turn in the middle.
|
|
6
|
+
*
|
|
7
|
+
* NOTE: hand-authored per the documented public Claude Code CLI stream-json
|
|
8
|
+
* schema (the same shapes the fixtures beside this one use). Numbers are
|
|
9
|
+
* chosen so each attribution bucket's totals are distinct and checkable.
|
|
10
|
+
*
|
|
11
|
+
* Shape of the session:
|
|
12
|
+
* - one `system`/`init` line
|
|
13
|
+
* - `msg_1` (root): text only, usage = the first-turn baseline
|
|
14
|
+
* - `msg_2` (root): a `Skill` tool_use invoking "shipwright:task-store"
|
|
15
|
+
* — emitted twice with the same message id (dedupe), the second line
|
|
16
|
+
* carrying the usage
|
|
17
|
+
* - a `user` tool_result line (no usage)
|
|
18
|
+
* - `msg_3` (skill): first usage-bearing turn after the invoke — its
|
|
19
|
+
* input + cache_creation is the skill's invokeContextDelta; it also fires
|
|
20
|
+
* an `Agent` tool_use (subagent_type "Explore")
|
|
21
|
+
* - `sub_1` (agent): a sub-agent turn carrying `parent_tool_use_id`
|
|
22
|
+
* - `msg_4` (skill): a plain Read tool_use + usage, still charged to the skill
|
|
23
|
+
* - one terminal `result`/success line
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { RunTelemetry } from "@app-vitals/agent-runtime/run-telemetry";
|
|
27
|
+
|
|
28
|
+
const SONNET = "claude-sonnet-4-6";
|
|
29
|
+
const SESSION_ID = "sess-skill-attribution";
|
|
30
|
+
|
|
31
|
+
export const lines: string[] = [
|
|
32
|
+
JSON.stringify({
|
|
33
|
+
type: "system",
|
|
34
|
+
subtype: "init",
|
|
35
|
+
session_id: SESSION_ID,
|
|
36
|
+
model: SONNET,
|
|
37
|
+
tools: ["Read", "Skill", "Agent"],
|
|
38
|
+
}),
|
|
39
|
+
JSON.stringify({
|
|
40
|
+
type: "assistant",
|
|
41
|
+
message: {
|
|
42
|
+
id: "msg_1",
|
|
43
|
+
role: "assistant",
|
|
44
|
+
model: SONNET,
|
|
45
|
+
content: [{ type: "text", text: "Starting." }],
|
|
46
|
+
usage: {
|
|
47
|
+
input_tokens: 2,
|
|
48
|
+
output_tokens: 10,
|
|
49
|
+
cache_read_input_tokens: 30_000,
|
|
50
|
+
cache_creation_input_tokens: 40_000,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
session_id: SESSION_ID,
|
|
54
|
+
}),
|
|
55
|
+
// msg_2 — Skill invoke, first line has no usage yet
|
|
56
|
+
JSON.stringify({
|
|
57
|
+
type: "assistant",
|
|
58
|
+
message: {
|
|
59
|
+
id: "msg_2",
|
|
60
|
+
role: "assistant",
|
|
61
|
+
model: SONNET,
|
|
62
|
+
content: [
|
|
63
|
+
{
|
|
64
|
+
type: "tool_use",
|
|
65
|
+
id: "tu_skill",
|
|
66
|
+
name: "Skill",
|
|
67
|
+
input: { skill: "shipwright:task-store" },
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
session_id: SESSION_ID,
|
|
72
|
+
}),
|
|
73
|
+
// msg_2 — same message id repeated, now with usage (must count once)
|
|
74
|
+
JSON.stringify({
|
|
75
|
+
type: "assistant",
|
|
76
|
+
message: {
|
|
77
|
+
id: "msg_2",
|
|
78
|
+
role: "assistant",
|
|
79
|
+
model: SONNET,
|
|
80
|
+
content: [
|
|
81
|
+
{
|
|
82
|
+
type: "tool_use",
|
|
83
|
+
id: "tu_skill",
|
|
84
|
+
name: "Skill",
|
|
85
|
+
input: { skill: "shipwright:task-store" },
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
usage: {
|
|
89
|
+
input_tokens: 3,
|
|
90
|
+
output_tokens: 20,
|
|
91
|
+
cache_read_input_tokens: 70_000,
|
|
92
|
+
cache_creation_input_tokens: 100,
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
session_id: SESSION_ID,
|
|
96
|
+
}),
|
|
97
|
+
JSON.stringify({
|
|
98
|
+
type: "user",
|
|
99
|
+
message: {
|
|
100
|
+
role: "user",
|
|
101
|
+
content: [
|
|
102
|
+
{
|
|
103
|
+
type: "tool_result",
|
|
104
|
+
tool_use_id: "tu_skill",
|
|
105
|
+
content: "<skill body>",
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
session_id: SESSION_ID,
|
|
110
|
+
}),
|
|
111
|
+
// msg_3 — first turn after the invoke: cache_creation carries the skill body
|
|
112
|
+
JSON.stringify({
|
|
113
|
+
type: "assistant",
|
|
114
|
+
message: {
|
|
115
|
+
id: "msg_3",
|
|
116
|
+
role: "assistant",
|
|
117
|
+
model: SONNET,
|
|
118
|
+
content: [
|
|
119
|
+
{
|
|
120
|
+
type: "tool_use",
|
|
121
|
+
id: "tu_agent",
|
|
122
|
+
name: "Agent",
|
|
123
|
+
input: { subagent_type: "Explore", prompt: "find things" },
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
usage: {
|
|
127
|
+
input_tokens: 4,
|
|
128
|
+
output_tokens: 30,
|
|
129
|
+
cache_read_input_tokens: 70_100,
|
|
130
|
+
cache_creation_input_tokens: 9_000,
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
session_id: SESSION_ID,
|
|
134
|
+
}),
|
|
135
|
+
// sub_1 — sub-agent turn
|
|
136
|
+
JSON.stringify({
|
|
137
|
+
type: "assistant",
|
|
138
|
+
parent_tool_use_id: "tu_agent",
|
|
139
|
+
message: {
|
|
140
|
+
id: "sub_1",
|
|
141
|
+
role: "assistant",
|
|
142
|
+
model: SONNET,
|
|
143
|
+
content: [{ type: "text", text: "found it" }],
|
|
144
|
+
usage: {
|
|
145
|
+
input_tokens: 500,
|
|
146
|
+
output_tokens: 40,
|
|
147
|
+
cache_read_input_tokens: 0,
|
|
148
|
+
cache_creation_input_tokens: 0,
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
session_id: SESSION_ID,
|
|
152
|
+
}),
|
|
153
|
+
JSON.stringify({
|
|
154
|
+
type: "user",
|
|
155
|
+
message: {
|
|
156
|
+
role: "user",
|
|
157
|
+
content: [
|
|
158
|
+
{ type: "tool_result", tool_use_id: "tu_agent", content: "report" },
|
|
159
|
+
],
|
|
160
|
+
},
|
|
161
|
+
session_id: SESSION_ID,
|
|
162
|
+
}),
|
|
163
|
+
// msg_4 — still attributed to the skill
|
|
164
|
+
JSON.stringify({
|
|
165
|
+
type: "assistant",
|
|
166
|
+
message: {
|
|
167
|
+
id: "msg_4",
|
|
168
|
+
role: "assistant",
|
|
169
|
+
model: SONNET,
|
|
170
|
+
content: [{ type: "tool_use", id: "tu_read", name: "Read", input: {} }],
|
|
171
|
+
usage: {
|
|
172
|
+
input_tokens: 5,
|
|
173
|
+
output_tokens: 50,
|
|
174
|
+
cache_read_input_tokens: 79_100,
|
|
175
|
+
cache_creation_input_tokens: 200,
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
session_id: SESSION_ID,
|
|
179
|
+
}),
|
|
180
|
+
JSON.stringify({
|
|
181
|
+
type: "result",
|
|
182
|
+
subtype: "success",
|
|
183
|
+
result: "done",
|
|
184
|
+
session_id: SESSION_ID,
|
|
185
|
+
is_error: false,
|
|
186
|
+
usage: {
|
|
187
|
+
input_tokens: 514,
|
|
188
|
+
output_tokens: 150,
|
|
189
|
+
cache_read_input_tokens: 249_200,
|
|
190
|
+
cache_creation_input_tokens: 49_300,
|
|
191
|
+
},
|
|
192
|
+
total_cost_usd: 0.42,
|
|
193
|
+
modelUsage: {
|
|
194
|
+
[SONNET]: {
|
|
195
|
+
inputTokens: 514,
|
|
196
|
+
outputTokens: 150,
|
|
197
|
+
cacheReadInputTokens: 249_200,
|
|
198
|
+
cacheCreationInputTokens: 49_300,
|
|
199
|
+
costUSD: 0.42,
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
}),
|
|
203
|
+
];
|
|
204
|
+
|
|
205
|
+
export const expectedTelemetry: RunTelemetry = {
|
|
206
|
+
firstTurn: {
|
|
207
|
+
model: SONNET,
|
|
208
|
+
messageId: "msg_1",
|
|
209
|
+
inputTokens: 2,
|
|
210
|
+
cacheCreationTokens: 40_000,
|
|
211
|
+
cacheReadTokens: 30_000,
|
|
212
|
+
contextTokens: 70_002,
|
|
213
|
+
},
|
|
214
|
+
turns: 5,
|
|
215
|
+
toolCalls: 3,
|
|
216
|
+
skillUsage: [
|
|
217
|
+
{
|
|
218
|
+
kind: "root",
|
|
219
|
+
name: "root",
|
|
220
|
+
invocations: 0,
|
|
221
|
+
turns: 1,
|
|
222
|
+
inputTokens: 2,
|
|
223
|
+
outputTokens: 10,
|
|
224
|
+
cacheReadTokens: 30_000,
|
|
225
|
+
cacheCreationTokens: 40_000,
|
|
226
|
+
invokeContextDelta: null,
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
kind: "skill",
|
|
230
|
+
name: "shipwright:task-store",
|
|
231
|
+
invocations: 1,
|
|
232
|
+
turns: 3,
|
|
233
|
+
inputTokens: 12,
|
|
234
|
+
outputTokens: 100,
|
|
235
|
+
cacheReadTokens: 219_200,
|
|
236
|
+
cacheCreationTokens: 9_300,
|
|
237
|
+
invokeContextDelta: 3 + 100,
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
kind: "agent",
|
|
241
|
+
name: "Explore",
|
|
242
|
+
invocations: 1,
|
|
243
|
+
turns: 1,
|
|
244
|
+
inputTokens: 500,
|
|
245
|
+
outputTokens: 40,
|
|
246
|
+
cacheReadTokens: 0,
|
|
247
|
+
cacheCreationTokens: 0,
|
|
248
|
+
invokeContextDelta: null,
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand-authored synthetic `claude -p --output-format stream-json --verbose`
|
|
3
|
+
* transcript: a session that is cut off mid-stream — two assistant turns are
|
|
4
|
+
* emitted but NO terminal `result` line ever arrives (truncated output /
|
|
5
|
+
* process killed).
|
|
6
|
+
*
|
|
7
|
+
* NOTE: hand-authored per the documented public schema, standing in for a real
|
|
8
|
+
* capture to be validated later. The parser must surface whatever per-model
|
|
9
|
+
* usage it accumulated from the `assistant` lines rather than discarding it.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const SONNET = "claude-sonnet-4-6";
|
|
13
|
+
const OPUS = "claude-opus-4-8";
|
|
14
|
+
const SESSION_ID = "sess-truncated";
|
|
15
|
+
|
|
16
|
+
export const lines: string[] = [
|
|
17
|
+
JSON.stringify({
|
|
18
|
+
type: "system",
|
|
19
|
+
subtype: "init",
|
|
20
|
+
session_id: SESSION_ID,
|
|
21
|
+
model: SONNET,
|
|
22
|
+
}),
|
|
23
|
+
JSON.stringify({
|
|
24
|
+
type: "assistant",
|
|
25
|
+
message: {
|
|
26
|
+
id: "msg_a",
|
|
27
|
+
role: "assistant",
|
|
28
|
+
model: SONNET,
|
|
29
|
+
content: [{ type: "text", text: "Working on it..." }],
|
|
30
|
+
usage: {
|
|
31
|
+
input_tokens: 100,
|
|
32
|
+
output_tokens: 40,
|
|
33
|
+
cache_read_input_tokens: 5,
|
|
34
|
+
cache_creation_input_tokens: 0,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
session_id: SESSION_ID,
|
|
38
|
+
}),
|
|
39
|
+
JSON.stringify({
|
|
40
|
+
type: "assistant",
|
|
41
|
+
message: {
|
|
42
|
+
id: "msg_b",
|
|
43
|
+
role: "assistant",
|
|
44
|
+
model: OPUS,
|
|
45
|
+
content: [{ type: "text", text: "Almost done" }],
|
|
46
|
+
usage: {
|
|
47
|
+
input_tokens: 150,
|
|
48
|
+
output_tokens: 60,
|
|
49
|
+
cache_read_input_tokens: 0,
|
|
50
|
+
cache_creation_input_tokens: 10,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
session_id: SESSION_ID,
|
|
54
|
+
}),
|
|
55
|
+
// stream is cut off here — no `result` line
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Everything the parser accumulated before the stream ended. costUSD is 0
|
|
60
|
+
* (never learned the authoritative cost — no result event arrived).
|
|
61
|
+
*/
|
|
62
|
+
export const expectedAccumulated = {
|
|
63
|
+
[SONNET]: {
|
|
64
|
+
inputTokens: 100,
|
|
65
|
+
outputTokens: 40,
|
|
66
|
+
cacheReadInputTokens: 5,
|
|
67
|
+
cacheCreationInputTokens: 0,
|
|
68
|
+
costUSD: 0,
|
|
69
|
+
},
|
|
70
|
+
[OPUS]: {
|
|
71
|
+
inputTokens: 150,
|
|
72
|
+
outputTokens: 60,
|
|
73
|
+
cacheReadInputTokens: 0,
|
|
74
|
+
cacheCreationInputTokens: 10,
|
|
75
|
+
costUSD: 0,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The `session_id` carried on the leading `system`/`init` line — the only
|
|
81
|
+
* place a session id is available in this fixture, since the stream never
|
|
82
|
+
* reaches a terminal `result` event.
|
|
83
|
+
*/
|
|
84
|
+
export const expectedSessionId = SESSION_ID;
|
package/format.ts
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// Markdown → Slack mrkdwn converter
|
|
2
|
+
// Converts GitHub-flavored markdown to Slack format
|
|
3
|
+
|
|
4
|
+
const TABLE_REGEX =
|
|
5
|
+
/(\|[^\n]+\|\n)([ \t]*\|[ \t]*[-:]+[ \t]*(?:\|[ \t]*[-:]+[ \t]*)*\|[ \t]*\n)((?:[ \t]*\|[^\n]+\|\n?)*)/gm;
|
|
6
|
+
|
|
7
|
+
export function markdownToSlack(text: string): string {
|
|
8
|
+
return (
|
|
9
|
+
text
|
|
10
|
+
// Tables → code blocks (fallback for non-block contexts)
|
|
11
|
+
.replace(TABLE_REGEX, (match) => {
|
|
12
|
+
return `\`\`\`\n${match.trimEnd()}\n\`\`\``;
|
|
13
|
+
})
|
|
14
|
+
// Italic (but not bold) — must run before heading/bold so the
|
|
15
|
+
// lookbehind/lookahead can distinguish *italic* from **bold** in
|
|
16
|
+
// the original markdown input. After the bold pass, **bold** becomes
|
|
17
|
+
// *bold* and the regex would incorrectly convert it to _bold_ (italic).
|
|
18
|
+
.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, "_$1_")
|
|
19
|
+
// Headings → bold
|
|
20
|
+
.replace(/^#{1,6}\s+(.+)$/gm, "*$1*")
|
|
21
|
+
// Bold
|
|
22
|
+
.replace(/\*\*(.+?)\*\*/g, "*$1*")
|
|
23
|
+
// Inline code
|
|
24
|
+
.replace(/`([^`]+)`/g, "`$1`")
|
|
25
|
+
// Links
|
|
26
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "<$2|$1>")
|
|
27
|
+
// Horizontal rules
|
|
28
|
+
.replace(/^---+$/gm, "─────────────────────")
|
|
29
|
+
.trim()
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const SECTION_TEXT_LIMIT = 3000;
|
|
34
|
+
|
|
35
|
+
type RawTextCell = { type: "raw_text"; text: string };
|
|
36
|
+
type TableBlock = {
|
|
37
|
+
type: "table";
|
|
38
|
+
column_settings: Array<{ align: "left" | "center" | "right" }>;
|
|
39
|
+
rows: RawTextCell[][];
|
|
40
|
+
};
|
|
41
|
+
type SectionBlock = { type: "section"; text: { type: "mrkdwn"; text: string } };
|
|
42
|
+
export type SlackBlock = TableBlock | SectionBlock;
|
|
43
|
+
|
|
44
|
+
function pushSectionBlocks(blocks: SlackBlock[], mrkdwn: string): void {
|
|
45
|
+
if (!mrkdwn) return;
|
|
46
|
+
for (let i = 0; i < mrkdwn.length; i += SECTION_TEXT_LIMIT) {
|
|
47
|
+
blocks.push({
|
|
48
|
+
type: "section",
|
|
49
|
+
text: { type: "mrkdwn", text: mrkdwn.slice(i, i + SECTION_TEXT_LIMIT) },
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseTableRows(tableStr: string): string[][] {
|
|
55
|
+
const lines = tableStr.trim().split("\n").filter(Boolean);
|
|
56
|
+
const parseRow = (line: string) =>
|
|
57
|
+
line
|
|
58
|
+
.split("|")
|
|
59
|
+
.slice(1, -1)
|
|
60
|
+
.map((c) => c.trim());
|
|
61
|
+
// lines[0] = header, lines[1] = separator, lines[2+] = data rows
|
|
62
|
+
return [parseRow(lines[0]), ...lines.slice(2).map(parseRow)];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Converts markdown to Slack Block Kit blocks when tables are present.
|
|
67
|
+
* Returns null when no tables are found (fall back to markdownToSlack).
|
|
68
|
+
*/
|
|
69
|
+
export function markdownToBlocks(
|
|
70
|
+
text: string,
|
|
71
|
+
): { text: string; blocks: SlackBlock[] } | null {
|
|
72
|
+
const regex = new RegExp(TABLE_REGEX.source, TABLE_REGEX.flags);
|
|
73
|
+
if (!regex.test(text)) return null;
|
|
74
|
+
|
|
75
|
+
regex.lastIndex = 0;
|
|
76
|
+
const blocks: SlackBlock[] = [];
|
|
77
|
+
let lastIndex = 0;
|
|
78
|
+
|
|
79
|
+
for (let match = regex.exec(text); match !== null; match = regex.exec(text)) {
|
|
80
|
+
const before = text.slice(lastIndex, match.index).trim();
|
|
81
|
+
if (before) {
|
|
82
|
+
pushSectionBlocks(blocks, markdownToSlack(before));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const rows = parseTableRows(match[0]);
|
|
86
|
+
const columnCount = rows[0]?.length ?? 0;
|
|
87
|
+
blocks.push({
|
|
88
|
+
type: "table",
|
|
89
|
+
column_settings: Array.from({ length: columnCount }, () => ({
|
|
90
|
+
align: "left" as const,
|
|
91
|
+
})),
|
|
92
|
+
rows: rows.map((row) =>
|
|
93
|
+
// Slack's table block rejects a cell whose text is empty ("must be more
|
|
94
|
+
// than 0 characters"), which a blank corner/header cell produces and
|
|
95
|
+
// fails the whole message with invalid_blocks. Coerce empties to a
|
|
96
|
+
// non-breaking space: a real character (passes validation) that still
|
|
97
|
+
// renders blank.
|
|
98
|
+
row.map((cell) => ({
|
|
99
|
+
type: "raw_text" as const,
|
|
100
|
+
text: cell || " ",
|
|
101
|
+
})),
|
|
102
|
+
),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
lastIndex = match.index + match[0].length;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const after = text.slice(lastIndex).trim();
|
|
109
|
+
if (after) {
|
|
110
|
+
pushSectionBlocks(blocks, markdownToSlack(after));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { text: markdownToSlack(text), blocks };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── Slack rich_text → Markdown ──────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
type TextStyle = {
|
|
119
|
+
bold?: boolean;
|
|
120
|
+
italic?: boolean;
|
|
121
|
+
code?: boolean;
|
|
122
|
+
strike?: boolean;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
type RichTextElement =
|
|
126
|
+
| { type: "text"; text: string; style?: TextStyle }
|
|
127
|
+
| { type: "link"; url: string; text?: string }
|
|
128
|
+
| { type: "emoji"; name: string }
|
|
129
|
+
| { type: "user"; user_id: string }
|
|
130
|
+
| { type: string; [key: string]: unknown };
|
|
131
|
+
|
|
132
|
+
type RichTextSection = {
|
|
133
|
+
type: "rich_text_section";
|
|
134
|
+
elements: RichTextElement[];
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
type RichTextPreformatted = {
|
|
138
|
+
type: "rich_text_preformatted";
|
|
139
|
+
elements: RichTextElement[];
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
type RichTextList = {
|
|
143
|
+
type: "rich_text_list";
|
|
144
|
+
style: "bullet" | "ordered";
|
|
145
|
+
elements: RichTextSection[];
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
type RichTextQuote = {
|
|
149
|
+
type: "rich_text_quote";
|
|
150
|
+
elements: RichTextElement[];
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
type RichTextBlockElement =
|
|
154
|
+
| RichTextSection
|
|
155
|
+
| RichTextPreformatted
|
|
156
|
+
| RichTextList
|
|
157
|
+
| RichTextQuote
|
|
158
|
+
| { type: string; [key: string]: unknown };
|
|
159
|
+
|
|
160
|
+
type RichTextBlock = {
|
|
161
|
+
type: "rich_text";
|
|
162
|
+
elements: RichTextBlockElement[];
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
function isTextElement(
|
|
166
|
+
el: RichTextElement,
|
|
167
|
+
): el is Extract<RichTextElement, { type: "text" }> {
|
|
168
|
+
return el.type === "text";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isLinkElement(
|
|
172
|
+
el: RichTextElement,
|
|
173
|
+
): el is Extract<RichTextElement, { type: "link" }> {
|
|
174
|
+
return el.type === "link";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isEmojiElement(
|
|
178
|
+
el: RichTextElement,
|
|
179
|
+
): el is Extract<RichTextElement, { type: "emoji" }> {
|
|
180
|
+
return el.type === "emoji";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isUserElement(
|
|
184
|
+
el: RichTextElement,
|
|
185
|
+
): el is Extract<RichTextElement, { type: "user" }> {
|
|
186
|
+
return el.type === "user";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function convertInlineElement(el: RichTextElement): string {
|
|
190
|
+
if (isTextElement(el)) {
|
|
191
|
+
const style = el.style ?? {};
|
|
192
|
+
let text = el.text;
|
|
193
|
+
if (style.code) text = `\`${text}\``;
|
|
194
|
+
if (style.bold) text = `**${text}**`;
|
|
195
|
+
if (style.italic) text = `*${text}*`;
|
|
196
|
+
if (style.strike) text = `~~${text}~~`;
|
|
197
|
+
return text;
|
|
198
|
+
}
|
|
199
|
+
if (isLinkElement(el)) {
|
|
200
|
+
return el.text ? `[${el.text}](${el.url})` : el.url;
|
|
201
|
+
}
|
|
202
|
+
if (isEmojiElement(el)) {
|
|
203
|
+
return `:${el.name}:`;
|
|
204
|
+
}
|
|
205
|
+
if (isUserElement(el)) {
|
|
206
|
+
return `<@${el.user_id}>`;
|
|
207
|
+
}
|
|
208
|
+
return "";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function convertSection(section: RichTextSection): string {
|
|
212
|
+
return section.elements.map(convertInlineElement).join("");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function convertRichTextBlock(block: RichTextBlock): string {
|
|
216
|
+
const parts: string[] = [];
|
|
217
|
+
|
|
218
|
+
for (const el of block.elements) {
|
|
219
|
+
if (el.type === "rich_text_section") {
|
|
220
|
+
parts.push(convertSection(el as RichTextSection));
|
|
221
|
+
} else if (el.type === "rich_text_preformatted") {
|
|
222
|
+
const pre = el as RichTextPreformatted;
|
|
223
|
+
const inner = pre.elements.map(convertInlineElement).join("");
|
|
224
|
+
parts.push(`\`\`\`\n${inner}\n\`\`\``);
|
|
225
|
+
} else if (el.type === "rich_text_list") {
|
|
226
|
+
const list = el as RichTextList;
|
|
227
|
+
const items = list.elements.map((item, i) => {
|
|
228
|
+
const text = convertSection(item);
|
|
229
|
+
return list.style === "ordered" ? `${i + 1}. ${text}` : `- ${text}`;
|
|
230
|
+
});
|
|
231
|
+
parts.push(items.join("\n"));
|
|
232
|
+
} else if (el.type === "rich_text_quote") {
|
|
233
|
+
const quote = el as RichTextQuote;
|
|
234
|
+
const inner = quote.elements.map(convertInlineElement).join("");
|
|
235
|
+
const quoted = inner
|
|
236
|
+
.split("\n")
|
|
237
|
+
.map((line) => `> ${line}`)
|
|
238
|
+
.join("\n");
|
|
239
|
+
parts.push(quoted);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return parts.join("\n\n");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Converts Slack rich_text blocks to markdown.
|
|
248
|
+
* Walks the blocks array for any `{type: "rich_text"}` block and converts
|
|
249
|
+
* its elements to markdown. Returns empty string if no rich_text block found.
|
|
250
|
+
*/
|
|
251
|
+
export function richTextToMarkdown(blocks: unknown[]): string {
|
|
252
|
+
const richBlocks = blocks.filter(
|
|
253
|
+
(b): b is RichTextBlock =>
|
|
254
|
+
b != null &&
|
|
255
|
+
typeof b === "object" &&
|
|
256
|
+
"type" in b &&
|
|
257
|
+
b.type === "rich_text",
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
if (richBlocks.length === 0) return "";
|
|
261
|
+
|
|
262
|
+
return richBlocks.map(convertRichTextBlock).join("\n");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Builds a Slack message for a shareable plan/spec link: a single mrkdwn
|
|
267
|
+
* section block (`<url|View plan>`, the Slack link form `markdownToSlack`
|
|
268
|
+
* emits) plus a plain-text fallback. Used by the [plan:url] response marker.
|
|
269
|
+
*/
|
|
270
|
+
export function formatPlanLink(url: string): {
|
|
271
|
+
text: string;
|
|
272
|
+
blocks: SlackBlock[];
|
|
273
|
+
} {
|
|
274
|
+
return {
|
|
275
|
+
text: `View plan: ${url}`,
|
|
276
|
+
blocks: [
|
|
277
|
+
{
|
|
278
|
+
type: "section",
|
|
279
|
+
text: { type: "mrkdwn", text: `<${url}|View plan>` },
|
|
280
|
+
},
|
|
281
|
+
],
|
|
282
|
+
};
|
|
283
|
+
}
|