@platless/axom-cli 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/README.md +64 -0
- package/dist/args.js +29 -0
- package/dist/cli.js +76 -0
- package/dist/commands/auth.js +52 -0
- package/dist/commands/execute.js +40 -0
- package/dist/commands/guide.js +38 -0
- package/dist/commands/record.js +99 -0
- package/dist/commands/skills.js +69 -0
- package/dist/commands/struct.js +50 -0
- package/dist/commands/update.js +88 -0
- package/dist/commands/whoami.js +8 -0
- package/dist/commands/workstation.js +171 -0
- package/dist/config.js +131 -0
- package/dist/credentialBroker.js +340 -0
- package/dist/credentials.js +211 -0
- package/dist/install/agentsDir.js +35 -0
- package/dist/install/claude/commands.js +89 -0
- package/dist/install/claude/materialize.js +42 -0
- package/dist/install/installer.js +45 -0
- package/dist/install/targets/claude.js +154 -0
- package/dist/mcp.js +152 -0
- package/dist/output.js +37 -0
- package/dist/workstation/acp/acpAgents.js +1287 -0
- package/dist/workstation/acp/acpClient.js +691 -0
- package/dist/workstation/acp/acpSession.js +1047 -0
- package/dist/workstation/acp/adoption.js +396 -0
- package/dist/workstation/acp/auth.js +80 -0
- package/dist/workstation/acp/authUrlSniffer.js +52 -0
- package/dist/workstation/acp/binaryInstall.js +411 -0
- package/dist/workstation/acp/configOptions.js +257 -0
- package/dist/workstation/acp/contract.js +494 -0
- package/dist/workstation/acp/draftInterceptor.js +154 -0
- package/dist/workstation/acp/draftSubagents.js +38 -0
- package/dist/workstation/acp/installErrors.js +89 -0
- package/dist/workstation/acp/installProbe.js +363 -0
- package/dist/workstation/acp/permission.js +327 -0
- package/dist/workstation/acp/registry/catalog.js +29 -0
- package/dist/workstation/acp/registry/launch.js +128 -0
- package/dist/workstation/acp/registry/registryClient.js +160 -0
- package/dist/workstation/acp/registry/registryTypes.js +8 -0
- package/dist/workstation/acp/sessionUpdateMapper.js +602 -0
- package/dist/workstation/acp/test/fakeAgent.js +435 -0
- package/dist/workstation/acp/test/sessionUpdateFixtures.js +165 -0
- package/dist/workstation/acp/timedCommand.js +47 -0
- package/dist/workstation/acp/uvxInstall.js +248 -0
- package/dist/workstation/acp/validate.js +134 -0
- package/dist/workstation/agentSessions.js +1628 -0
- package/dist/workstation/channels/agent.js +96 -0
- package/dist/workstation/channels/computer.js +91 -0
- package/dist/workstation/channels/management.js +231 -0
- package/dist/workstation/channels/url.js +42 -0
- package/dist/workstation/config.js +47 -0
- package/dist/workstation/connection/encryptedChannel.js +261 -0
- package/dist/workstation/connection/retryPolicy.js +14 -0
- package/dist/workstation/connection/supervisor.js +353 -0
- package/dist/workstation/crypto.js +63 -0
- package/dist/workstation/daemon.js +225 -0
- package/dist/workstation/daemonctl.js +95 -0
- package/dist/workstation/exec.js +112 -0
- package/dist/workstation/journal.js +257 -0
- package/dist/workstation/names.js +17 -0
- package/dist/workstation/oauth/browser.js +24 -0
- package/dist/workstation/oauth/deviceAuthorization.js +198 -0
- package/dist/workstation/osinfo.js +4 -0
- package/dist/workstation/protocol/agent.js +606 -0
- package/dist/workstation/protocol/common.js +136 -0
- package/dist/workstation/protocol/computer.js +31 -0
- package/dist/workstation/protocol/management.js +116 -0
- package/dist/workstation/register.js +42 -0
- package/dist/workstation/service/installer.js +362 -0
- package/dist/workstation/service/launchd.js +46 -0
- package/dist/workstation/service/loginShell.js +36 -0
- package/dist/workstation/service/systemd.js +48 -0
- package/dist/workstation/service/types.js +73 -0
- package/dist/workstation/supervision.js +52 -0
- package/dist/workstation/systemMetrics.js +271 -0
- package/dist/workstation/update/artifact.js +61 -0
- package/dist/workstation/update/coordinator.js +240 -0
- package/dist/workstation/update/npmInstaller.js +91 -0
- package/dist/workstation/update/redact.js +36 -0
- package/dist/workstation/update/runUpdate.js +215 -0
- package/dist/workstation/update/stateStore.js +87 -0
- package/dist/workstation/update/taskRegistry.js +142 -0
- package/dist/workstation/update/types.js +3 -0
- package/dist/workstation/update/watchdog.js +311 -0
- package/dist/workstation/version.js +12 -0
- package/package.json +39 -0
- package/skills/axom/SKILL.md +83 -0
- package/skills/axom-definition/SKILL.md +74 -0
- package/skills/axom-platform/SKILL.md +78 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
// Deliberately valid JavaScript, not a module TypeScript needs to type-check: `acpClient.test.ts`
|
|
3
|
+
// runs this fixture as a real, separate OS process that speaks ACP over ndjson stdio, through a
|
|
4
|
+
// Node 20-safe `node -e` bootstrap: `eval(readFileSync(process.argv[1], 'utf8'))`. (The trick was
|
|
5
|
+
// borrowed from the Codex app-server fake, `src/test/fakeAppServer.ts`, which Task 8 deleted along
|
|
6
|
+
// with the native bridges — the path is history, not somewhere to look.) That bootstrap evaluates
|
|
7
|
+
// this file's raw source as plain JavaScript — it never goes through `tsc`, so top-level `import`/
|
|
8
|
+
// `export` syntax would be a SyntaxError here. `require()` reaches Node builtins; the ACP SDK is
|
|
9
|
+
// ESM-only (no CJS build), so it is reached with a dynamic `import()`, which works from CJS too.
|
|
10
|
+
//
|
|
11
|
+
// This is a REAL ACP agent built on the SDK's own `agent()` builder — not a hand-rolled stand-in
|
|
12
|
+
// for our own client. The contract suite (a later task) depends on that being true, and depends on
|
|
13
|
+
// this fixture being able to present different capability sets so tests can cover both "advertises
|
|
14
|
+
// X" and "does not advertise X" without forking the file. Everything an agent would normally decide
|
|
15
|
+
// for itself (capabilities, authMethods, the session id `session/new` hands back, and the
|
|
16
|
+
// `session/update` notifications `session/prompt` sends before it stops) is instead read from a
|
|
17
|
+
// JSON config on argv[2], so callers configure behavior without touching this file.
|
|
18
|
+
//
|
|
19
|
+
// Config shape (all fields optional):
|
|
20
|
+
// {
|
|
21
|
+
// agentCapabilities?: AgentCapabilities, // passed through verbatim into InitializeResponse
|
|
22
|
+
// authMethods?: AuthMethod[], // passed through verbatim into InitializeResponse
|
|
23
|
+
// agentInfo?: Implementation,
|
|
24
|
+
// initializeMeta?: object, // passed through verbatim as InitializeResponse._meta
|
|
25
|
+
// // — the TOP-LEVEL one, a sibling of
|
|
26
|
+
// // agentCapabilities. That is where the steering
|
|
27
|
+
// // extension advertises itself
|
|
28
|
+
// // (`claude-agent-acp` `acp-agent.js@0.76.0:944-950`),
|
|
29
|
+
// // so a test can present `{steering:{supported:true}}`
|
|
30
|
+
// // and, just as importantly, an agent that says
|
|
31
|
+
// // nothing at all
|
|
32
|
+
// sessionId?: string, // default 'fake-session'
|
|
33
|
+
// updates?: SessionUpdate[], // sent as session/update notifications during prompt
|
|
34
|
+
// stopReason?: StopReason, // default 'end_turn'
|
|
35
|
+
// protocolVersionSink?: string, // if set, the received initialize().protocolVersion
|
|
36
|
+
// // is written to this file path (test-only escape
|
|
37
|
+
// // hatch: InitializeResponse doesn't have to echo it,
|
|
38
|
+
// // so this is how a test observes what was sent)
|
|
39
|
+
// clientCapabilitiesSink?: string, // if set, the received initialize().clientCapabilities
|
|
40
|
+
// // is written to this file path as JSON (same escape
|
|
41
|
+
// // hatch as protocolVersionSink — InitializeResponse
|
|
42
|
+
// // has nowhere to echo what the client sent)
|
|
43
|
+
// protocolVersionOverride?: number, // if set, returned as initialize().protocolVersion
|
|
44
|
+
// // instead of the SDK's real PROTOCOL_VERSION —
|
|
45
|
+
// // lets a test simulate an agent that negotiated a
|
|
46
|
+
// // version this client should refuse
|
|
47
|
+
// hangOnInitialize?: boolean, // if true, initialize() never resolves — simulates
|
|
48
|
+
// // an agent that opened stdio but never speaks, for
|
|
49
|
+
// // exercising connectAgent's initialize timeout
|
|
50
|
+
// exitMarkerPath?: string, // if set, a file this process writes right before
|
|
51
|
+
// // exiting on SIGTERM — lets a test confirm the
|
|
52
|
+
// // process actually died after `child.kill()`
|
|
53
|
+
// cancelSink?: string, // if set, a file this process writes (the JSON
|
|
54
|
+
// // `session/cancel` params) the moment it receives
|
|
55
|
+
// // that notification — lets a test prove
|
|
56
|
+
// // `AcpSession.interrupt()` really sent
|
|
57
|
+
// // `session/cancel` and not, say, an aborted request
|
|
58
|
+
// hangUntilCancel?: boolean, // if true, session/prompt sends its `updates` and
|
|
59
|
+
// // then waits for a `session/cancel` notification
|
|
60
|
+
// // before returning, instead of returning right away
|
|
61
|
+
// // — gives a test a real in-flight turn to interrupt
|
|
62
|
+
// stopReasonAfterCancel?: StopReason, // stopReason returned once hangUntilCancel's wait is
|
|
63
|
+
// // over; default 'end_turn' — deliberately NOT
|
|
64
|
+
// // 'cancelled', so a test can prove AcpSession forces
|
|
65
|
+
// // status 'interrupted' off its own interrupted flag
|
|
66
|
+
// // rather than merely relaying the agent's stopReason
|
|
67
|
+
// pingDuringInitialize?: boolean, // if true, initialize() first calls the client-side
|
|
68
|
+
// // custom method `_test/ping` and writes the
|
|
69
|
+
// // response to pingResultSink before returning —
|
|
70
|
+
// // this is how a test proves a `connectAgent`
|
|
71
|
+
// // `configure` hook actually registered a handler
|
|
72
|
+
// // before `.connect()`, since it only works if the
|
|
73
|
+
// // handler exists in time to answer this call
|
|
74
|
+
// pingResultSink?: string,
|
|
75
|
+
// requestPermissionDuringPrompt?: { toolCall: ToolCallUpdate, options: PermissionOption[] },
|
|
76
|
+
// // if set, session/prompt calls the client's
|
|
77
|
+
// // session/request_permission with this payload
|
|
78
|
+
// // before returning, and writes the raw response to
|
|
79
|
+
// // requestPermissionResultSink — Task 5's proof that
|
|
80
|
+
// // its handler is reachable through connectAgent's
|
|
81
|
+
// // `configure` hook from a real separate process.
|
|
82
|
+
// requestPermissionResultSink?: string,
|
|
83
|
+
// createElicitationDuringPrompt?: object, // if set, session/prompt calls the client's
|
|
84
|
+
// // elicitation/create with this payload verbatim
|
|
85
|
+
// // before returning, and writes the raw response to
|
|
86
|
+
// // createElicitationResultSink.
|
|
87
|
+
// createElicitationResultSink?: string,
|
|
88
|
+
// authenticateSink?: string, // if set, a file this process writes (the raw JSON
|
|
89
|
+
// // `authenticate` params, i.e. `{ methodId }`) the
|
|
90
|
+
// // moment it receives that request — lets a test
|
|
91
|
+
// // prove `AcpAuth.authenticate` really sent the
|
|
92
|
+
// // request (vs. refusing before ever reaching the
|
|
93
|
+
// // agent for an unadvertised or terminal methodId)
|
|
94
|
+
// authenticateRejectsWith?: string, // if set, the authenticate handler throws an Error
|
|
95
|
+
// // with this message instead of succeeding — lets a
|
|
96
|
+
// // test prove a real agent-side rejection surfaces as
|
|
97
|
+
// // a rejected promise
|
|
98
|
+
// hangOnAuthenticate?: boolean, // if true, `authenticate` never resolves — the shape
|
|
99
|
+
// // measured on a real agent that opens a browser and
|
|
100
|
+
// // waits for its callback (spec §6.2: antigravity's
|
|
101
|
+
// // authenticate had not returned after 25s). Lets a
|
|
102
|
+
// // test prove a login is NOT cut off at the probe
|
|
103
|
+
// // budget the way `hangOnSessionNew` proves a probe is
|
|
104
|
+
// createElicitationDuringAuthenticate?: object,
|
|
105
|
+
// // if set, the `authenticate` handler calls the
|
|
106
|
+
// // client's `elicitation/create` with this payload
|
|
107
|
+
// // verbatim before returning, and writes the raw
|
|
108
|
+
// // response to createElicitationDuringAuthenticateResultSink.
|
|
109
|
+
// // This is the device-code login shape (Codex), and
|
|
110
|
+
// // the only way to prove the client registered its
|
|
111
|
+
// // elicitation handlers on the connection
|
|
112
|
+
// // `authenticate` actually runs on — a session
|
|
113
|
+
// // connection's handlers are unreachable from here.
|
|
114
|
+
// createElicitationDuringAuthenticateResultSink?: string,
|
|
115
|
+
// completeElicitationAfterAuthenticate?: string,
|
|
116
|
+
// // if set, the `authenticate` handler sends
|
|
117
|
+
// // `elicitation/complete` for this elicitationId
|
|
118
|
+
// // right after the elicitation above is answered —
|
|
119
|
+
// // the agent retiring its own URL, which is the only
|
|
120
|
+
// // provenance that can ever be retracted by the
|
|
121
|
+
// // protocol itself.
|
|
122
|
+
// stderrLines?: string[], // written to this process's stderr (one line each)
|
|
123
|
+
// // before it speaks any protocol at all — the
|
|
124
|
+
// // stderr-only login shape (antigravity), and it
|
|
125
|
+
// // starts at spawn on purpose, which is exactly the
|
|
126
|
+
// // window `ConnectAgentOptions.present` warns about.
|
|
127
|
+
// hangOnSessionNew?: boolean, // if true, session/new never resolves — the agent
|
|
128
|
+
// // answered initialize and then went quiet on the
|
|
129
|
+
// // very next request. Distinct from hangOnInitialize:
|
|
130
|
+
// // that one is caught by connectAgent's own
|
|
131
|
+
// // initializeTimeoutMs, this one is past it, so only
|
|
132
|
+
// // a deadline the CALLER imposes (AcpAgents.verify's
|
|
133
|
+
// // withDeadline) can end it. Task 10a's review
|
|
134
|
+
// // measured this path with a temporary flag and then
|
|
135
|
+
// // reverted it; Task 11 made it permanent.
|
|
136
|
+
// newSessionRejectsWith?: string, // if set, the session/new handler throws an Error with
|
|
137
|
+
// // this message instead of returning a sessionId —
|
|
138
|
+
// // the shape a real agent takes when it needs
|
|
139
|
+
// // credentials it does not have. Task 10a uses it to
|
|
140
|
+
// // prove a failed verification reports what happened
|
|
141
|
+
// // WITHOUT claiming the agent is unauthenticated,
|
|
142
|
+
// // which ACP v1 gives nobody the means to know.
|
|
143
|
+
// newSessionCommands?: AvailableCommand[][], // 每个元素是一次 available_commands_update 推送,
|
|
144
|
+
// // 在 session/new 回包之后按序发出(真适配器就是
|
|
145
|
+
// // 这么干的:setTimeout 0,见 acp-agent.js:756)。
|
|
146
|
+
// // 用数组的数组是因为「推几次」本身就是被测行为:
|
|
147
|
+
// // claude 会推两次(70 → 68),codex 只推一次。
|
|
148
|
+
// newSessionCommandsDelayMs?: number, // 每次推送之间的间隔,默认 0。让测试能把一条晚到的
|
|
149
|
+
// // 推送放在 settle 窗口的里面或外面。
|
|
150
|
+
// sessions?: SessionInfo[], // session/list's response `sessions` array; default []
|
|
151
|
+
// nextCursor?: string, // session/list's response `nextCursor`
|
|
152
|
+
// sessionListSink?: string, // if set, a file this process writes (the raw JSON
|
|
153
|
+
// // session/list params, i.e. `{cwd, cursor}`) the
|
|
154
|
+
// // moment it receives that request — Task 7's proof
|
|
155
|
+
// // that listNativeSessions really sent cwd/cursor
|
|
156
|
+
// loadUpdates?: SessionUpdate[], // Task 7 (adoption.ts): sent as session/update
|
|
157
|
+
// // notifications during session/load, before it
|
|
158
|
+
// // returns — this is the replay mechanism ACP v1
|
|
159
|
+
// // actually uses (session/resume does NOT replay
|
|
160
|
+
// // history; see adoption.ts's file doc comment)
|
|
161
|
+
// loadSessionRejectsWith?: string, // if set, the session/load handler throws an Error with
|
|
162
|
+
// // this message instead of replaying and succeeding —
|
|
163
|
+
// // lets a test prove a client falls back to session/new
|
|
164
|
+
// // when an advertised load is refused (a stale id)
|
|
165
|
+
// promptRejectsWith?: string, // if set, the session/prompt handler throws an Error with
|
|
166
|
+
// // this message instead of returning a stopReason — the
|
|
167
|
+
// // only way to exercise a rejected prompt against a real
|
|
168
|
+
// // agent (the SDK masks the text as "Internal error",
|
|
169
|
+
// // and the code as JSON-RPC -32603)
|
|
170
|
+
// promptRejectsWithAuthRequired?: boolean, // if set, session/prompt answers with ACP's own
|
|
171
|
+
// // `RequestError.authRequired()` (-32000) instead —
|
|
172
|
+
// // the shape credentials expiring mid-conversation
|
|
173
|
+
// // actually takes, and the only failure the login
|
|
174
|
+
// // card may be drawn for
|
|
175
|
+
// promptSink?: string, // if set, a file this process overwrites with the raw
|
|
176
|
+
// // JSON session/prompt params on every prompt — the
|
|
177
|
+
// // only way to observe WHICH sessionId a prompt was
|
|
178
|
+
// // addressed to, which is the whole point of attaching
|
|
179
|
+
// // to a loaded session rather than opening a new one
|
|
180
|
+
// loadSessionResponse?: LoadSessionResponse, // session/load's response; default {}
|
|
181
|
+
// loadSessionSink?: string, // if set, a file this process writes (the raw JSON
|
|
182
|
+
// // session/load params) the moment it receives that
|
|
183
|
+
// // request — lets a test prove the right
|
|
184
|
+
// // sessionId/cwd were sent, and (by NOT configuring
|
|
185
|
+
// // this and asserting the file never appears) that
|
|
186
|
+
// // adoption.ts never calls session/load when the
|
|
187
|
+
// // capability isn't advertised
|
|
188
|
+
// resumeSessionResponse?: ResumeSessionResponse, // session/resume's response; default {}
|
|
189
|
+
// resumeSessionSink?: string, // same idea as loadSessionSink, for session/resume
|
|
190
|
+
// // — Task 7's proof that adoption.ts NEVER calls
|
|
191
|
+
// // session/resume for replay (see adoption.ts's file
|
|
192
|
+
// // doc comment for why), even when the agent
|
|
193
|
+
// // advertises and answers it
|
|
194
|
+
// steerSink?: string, // if set, a file this process writes (the raw JSON
|
|
195
|
+
// // `_session/steering` params) the moment it receives
|
|
196
|
+
// // that request. `_session/steering` is an EXTENSION,
|
|
197
|
+
// // not ACP (`claude-agent-acp`
|
|
198
|
+
// // `acp-agent.js@0.76.0:164`),
|
|
199
|
+
// // which is why it is routed here by its literal
|
|
200
|
+
// // method name rather than through `methods.agent.*`
|
|
201
|
+
// steerOutcome?: 'injected'|'startedNewTurn'|'promptRequired',
|
|
202
|
+
// // the outcome `_session/steering` answers with;
|
|
203
|
+
// // default 'injected'. `startedNewTurn` is the shape
|
|
204
|
+
// // a contract-breaking agent takes — it is what an
|
|
205
|
+
// // agent returns to a client that did NOT opt into
|
|
206
|
+
// // `promptRequired`, so seeing it back means the
|
|
207
|
+
// // agent ignored the opt-in we always send
|
|
208
|
+
// steerRejectsWith?: string, // if set, the handler throws with this message
|
|
209
|
+
// newSessionConfigOptions?: SessionConfigOption[], // returned verbatim on session/new (types.gen.d.ts:2546)
|
|
210
|
+
// newSessionModes?: SessionModeState, // returned verbatim on session/new (:2542)
|
|
211
|
+
// setConfigOptionSink?: string, // file receiving the JSON params of each session/set_config_option
|
|
212
|
+
// setConfigOptionRejectsWith?: string, // if set, session/set_config_option throws
|
|
213
|
+
// setConfigOptionConfigOptions?: SessionConfigOption[], // if set, session/set_config_option answers with
|
|
214
|
+
// // exactly this list instead of echoing
|
|
215
|
+
// // `newSessionConfigOptions` with the value applied —
|
|
216
|
+
// // for the case where the agent's reply carries a
|
|
217
|
+
// // *different* set of options than the open did
|
|
218
|
+
// setModeSink?: string, // file receiving the JSON params of each session/set_mode
|
|
219
|
+
// }
|
|
220
|
+
//
|
|
221
|
+
// session/list, session/load and session/resume are ALWAYS routed (see the unconditional onRequest
|
|
222
|
+
// calls below), independent of whether `agentCapabilities.sessionCapabilities.list`/`.resume` or
|
|
223
|
+
// `agentCapabilities.loadSession` are advertised. That mirrors the real-world case this task exists
|
|
224
|
+
// to guard against — codex-acp routes session/fork without declaring it — and gives callers a
|
|
225
|
+
// fixture that can prove "honour the advertised capability, not the router" end to end, not just
|
|
226
|
+
// assert a boolean.
|
|
227
|
+
async function main() {
|
|
228
|
+
const { writeFileSync } = require('node:fs');
|
|
229
|
+
// Parsed and wired up before the (slow, cold-start) dynamic import below, so a caller that kills
|
|
230
|
+
// this process almost immediately after spawning it (exercising connectAgent's initialize
|
|
231
|
+
// timeout with a short deadline) still gets a SIGTERM handler in place. Registering this only
|
|
232
|
+
// after the SDK import resolved raced the timeout in practice.
|
|
233
|
+
const config = JSON.parse(process.argv[2] ?? '{}');
|
|
234
|
+
// Before the SDK import, deliberately: an agent that only ever prints its login URL does it while
|
|
235
|
+
// it is still starting up, and the client's sniffer is armed from `spawn()` for that reason.
|
|
236
|
+
for (const line of config.stderrLines ?? [])
|
|
237
|
+
process.stderr.write(`${line}\n`);
|
|
238
|
+
if (config.exitMarkerPath) {
|
|
239
|
+
process.on('SIGTERM', () => {
|
|
240
|
+
try {
|
|
241
|
+
writeFileSync(config.exitMarkerPath, 'killed');
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
const { agent, methods, PROTOCOL_VERSION, ndJsonStream, RequestError } = await import('@agentclientprotocol/sdk');
|
|
249
|
+
const { Readable, Writable } = require('node:stream');
|
|
250
|
+
const sessionId = config.sessionId ?? 'fake-session';
|
|
251
|
+
const updates = config.updates ?? [];
|
|
252
|
+
const stopReason = config.stopReason ?? 'end_turn';
|
|
253
|
+
let resolveCancelled;
|
|
254
|
+
const cancelled = new Promise((resolve) => {
|
|
255
|
+
resolveCancelled = resolve;
|
|
256
|
+
});
|
|
257
|
+
const app = agent({ name: 'fake-agent' })
|
|
258
|
+
.onRequest(methods.agent.initialize, async (ctx) => {
|
|
259
|
+
if (config.protocolVersionSink) {
|
|
260
|
+
writeFileSync(config.protocolVersionSink, String(ctx.params.protocolVersion));
|
|
261
|
+
}
|
|
262
|
+
if (config.clientCapabilitiesSink) {
|
|
263
|
+
writeFileSync(config.clientCapabilitiesSink, JSON.stringify(ctx.params.clientCapabilities ?? {}));
|
|
264
|
+
}
|
|
265
|
+
if (config.pingDuringInitialize) {
|
|
266
|
+
const pong = await ctx.client.request('_test/ping', { echo: 'hi' });
|
|
267
|
+
writeFileSync(config.pingResultSink, JSON.stringify(pong));
|
|
268
|
+
}
|
|
269
|
+
if (config.hangOnInitialize) {
|
|
270
|
+
return new Promise(() => { }); // never resolves
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
protocolVersion: config.protocolVersionOverride ?? PROTOCOL_VERSION,
|
|
274
|
+
agentCapabilities: config.agentCapabilities ?? {},
|
|
275
|
+
authMethods: config.authMethods ?? [],
|
|
276
|
+
...(config.agentInfo ? { agentInfo: config.agentInfo } : {}),
|
|
277
|
+
...(config.initializeMeta ? { _meta: config.initializeMeta } : {}),
|
|
278
|
+
};
|
|
279
|
+
})
|
|
280
|
+
.onRequest(methods.agent.authenticate, async (ctx) => {
|
|
281
|
+
if (config.authenticateSink)
|
|
282
|
+
writeFileSync(config.authenticateSink, JSON.stringify(ctx.params));
|
|
283
|
+
if (config.authenticateRejectsWith)
|
|
284
|
+
throw new Error(config.authenticateRejectsWith);
|
|
285
|
+
if (config.createElicitationDuringAuthenticate) {
|
|
286
|
+
// `requestId` is filled in here rather than in the config: a login elicitation is
|
|
287
|
+
// request-scoped (ACP's `ElicitationRequestScope` — "tied to a specific JSON-RPC request
|
|
288
|
+
// outside of a session, e.g. during auth phases before any session is started"), and only
|
|
289
|
+
// this handler knows the id of the `authenticate` it is answering.
|
|
290
|
+
const result = await ctx.client.request(methods.client.elicitation.create, {
|
|
291
|
+
requestId: ctx.requestId,
|
|
292
|
+
...config.createElicitationDuringAuthenticate,
|
|
293
|
+
});
|
|
294
|
+
if (config.createElicitationDuringAuthenticateResultSink) {
|
|
295
|
+
writeFileSync(config.createElicitationDuringAuthenticateResultSink, JSON.stringify(result));
|
|
296
|
+
}
|
|
297
|
+
if (config.completeElicitationAfterAuthenticate) {
|
|
298
|
+
await ctx.client.notify(methods.client.elicitation.complete, {
|
|
299
|
+
elicitationId: config.completeElicitationAfterAuthenticate,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// Last, so the flags above are independently exercisable: a login that hangs may well have
|
|
304
|
+
// handed out its URL first, which is precisely the case this fixture has to be able to model.
|
|
305
|
+
if (config.hangOnAuthenticate)
|
|
306
|
+
return new Promise(() => { });
|
|
307
|
+
return {};
|
|
308
|
+
})
|
|
309
|
+
.onRequest(methods.agent.session.new, async (ctx) => {
|
|
310
|
+
if (config.newSessionRejectsWith)
|
|
311
|
+
throw new Error(config.newSessionRejectsWith);
|
|
312
|
+
// Checked after the rejection so the two flags stay independently exercisable; a promise that
|
|
313
|
+
// never settles is the only faithful way to model an agent that simply stops answering.
|
|
314
|
+
if (config.hangOnSessionNew)
|
|
315
|
+
return new Promise(() => { });
|
|
316
|
+
// Pushed AFTER this handler returns, not before: the real adapter schedules its first
|
|
317
|
+
// `available_commands_update` on a `setTimeout(…, 0)` that runs once `session/new` has been
|
|
318
|
+
// answered (`acp-agent.js:752-757`). Sending them inline would let a client that only reads
|
|
319
|
+
// updates *before* the reply still pass, which is the bug this fixture exists to catch.
|
|
320
|
+
const pushes = config.newSessionCommands ?? [];
|
|
321
|
+
if (pushes.length > 0) {
|
|
322
|
+
void (async () => {
|
|
323
|
+
for (const availableCommands of pushes) {
|
|
324
|
+
await new Promise((r) => setTimeout(r, config.newSessionCommandsDelayMs ?? 0));
|
|
325
|
+
await ctx.client.notify(methods.client.session.update, {
|
|
326
|
+
sessionId,
|
|
327
|
+
update: { sessionUpdate: 'available_commands_update', availableCommands },
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
})();
|
|
331
|
+
}
|
|
332
|
+
return {
|
|
333
|
+
sessionId,
|
|
334
|
+
...(config.newSessionConfigOptions ? { configOptions: config.newSessionConfigOptions } : {}),
|
|
335
|
+
...(config.newSessionModes ? { modes: config.newSessionModes } : {}),
|
|
336
|
+
};
|
|
337
|
+
})
|
|
338
|
+
.onRequest(methods.agent.session.list, (ctx) => {
|
|
339
|
+
if (config.sessionListSink)
|
|
340
|
+
writeFileSync(config.sessionListSink, JSON.stringify(ctx.params));
|
|
341
|
+
return { sessions: config.sessions ?? [], ...(config.nextCursor ? { nextCursor: config.nextCursor } : {}) };
|
|
342
|
+
})
|
|
343
|
+
.onRequest(methods.agent.session.load, async (ctx) => {
|
|
344
|
+
if (config.loadSessionSink)
|
|
345
|
+
writeFileSync(config.loadSessionSink, JSON.stringify(ctx.params));
|
|
346
|
+
if (config.loadSessionRejectsWith)
|
|
347
|
+
throw new Error(config.loadSessionRejectsWith);
|
|
348
|
+
for (const update of config.loadUpdates ?? []) {
|
|
349
|
+
await ctx.client.notify(methods.client.session.update, { sessionId: ctx.params.sessionId, update });
|
|
350
|
+
}
|
|
351
|
+
return config.loadSessionResponse ?? {};
|
|
352
|
+
})
|
|
353
|
+
.onRequest(methods.agent.session.resume, async (ctx) => {
|
|
354
|
+
if (config.resumeSessionSink)
|
|
355
|
+
writeFileSync(config.resumeSessionSink, JSON.stringify(ctx.params));
|
|
356
|
+
return config.resumeSessionResponse ?? {};
|
|
357
|
+
})
|
|
358
|
+
.onRequest(methods.agent.session.setConfigOption, (ctx) => {
|
|
359
|
+
if (config.setConfigOptionSink)
|
|
360
|
+
writeFileSync(config.setConfigOptionSink, JSON.stringify(ctx.params));
|
|
361
|
+
if (config.setConfigOptionRejectsWith)
|
|
362
|
+
throw new Error(config.setConfigOptionRejectsWith);
|
|
363
|
+
if (config.setConfigOptionConfigOptions)
|
|
364
|
+
return { configOptions: config.setConfigOptionConfigOptions };
|
|
365
|
+
// Echo the snapshot with the requested value applied — the real adapters answer the full set
|
|
366
|
+
// (SetSessionConfigOptionResponse.configOptions, types.gen.d.ts:2953).
|
|
367
|
+
const next = (config.newSessionConfigOptions ?? []).map((o) => o.id === ctx.params.configId ? { ...o, currentValue: ctx.params.value } : o);
|
|
368
|
+
return { configOptions: next };
|
|
369
|
+
})
|
|
370
|
+
.onRequest(methods.agent.session.setMode, (ctx) => {
|
|
371
|
+
if (config.setModeSink)
|
|
372
|
+
writeFileSync(config.setModeSink, JSON.stringify(ctx.params));
|
|
373
|
+
return {};
|
|
374
|
+
})
|
|
375
|
+
.onNotification(methods.agent.session.cancel, (ctx) => {
|
|
376
|
+
if (config.cancelSink)
|
|
377
|
+
writeFileSync(config.cancelSink, JSON.stringify(ctx.params));
|
|
378
|
+
resolveCancelled();
|
|
379
|
+
})
|
|
380
|
+
.onRequest('_session/steering', (params) => params, (ctx) => {
|
|
381
|
+
if (config.steerSink)
|
|
382
|
+
writeFileSync(config.steerSink, JSON.stringify(ctx.params));
|
|
383
|
+
if (config.steerRejectsWith)
|
|
384
|
+
throw new Error(config.steerRejectsWith);
|
|
385
|
+
const outcome = config.steerOutcome ?? 'injected';
|
|
386
|
+
return outcome === 'promptRequired' ? { outcome, reason: 'noRunningTurn' } : { outcome };
|
|
387
|
+
})
|
|
388
|
+
.onRequest(methods.agent.session.prompt, async (ctx) => {
|
|
389
|
+
if (config.promptSink)
|
|
390
|
+
writeFileSync(config.promptSink, JSON.stringify(ctx.params));
|
|
391
|
+
// A plain Error is masked by the SDK as JSON-RPC -32603; `RequestError.authRequired()` is
|
|
392
|
+
// the real thing an agent whose credentials expired answers with (-32000). The two shapes are
|
|
393
|
+
// what the login card has to tell apart, and only a real agent process on a real wire proves
|
|
394
|
+
// the code survives the trip.
|
|
395
|
+
if (config.promptRejectsWithAuthRequired)
|
|
396
|
+
throw RequestError.authRequired();
|
|
397
|
+
if (config.promptRejectsWith)
|
|
398
|
+
throw new Error(config.promptRejectsWith);
|
|
399
|
+
for (const update of updates) {
|
|
400
|
+
await ctx.client.notify(methods.client.session.update, { sessionId: ctx.params.sessionId, update });
|
|
401
|
+
}
|
|
402
|
+
// Task 5 (permission.ts): if configured, calls the client's `session/request_permission`
|
|
403
|
+
// handler mid-turn and writes the raw response to a sink file — this is how a test proves the
|
|
404
|
+
// handler registered via `connectAgent`'s `configure` hook is really reachable from a separate
|
|
405
|
+
// OS process speaking real ACP, not just callable in-process.
|
|
406
|
+
if (config.requestPermissionDuringPrompt) {
|
|
407
|
+
const result = await ctx.client.request(methods.client.session.requestPermission, {
|
|
408
|
+
sessionId: ctx.params.sessionId,
|
|
409
|
+
toolCall: config.requestPermissionDuringPrompt.toolCall,
|
|
410
|
+
options: config.requestPermissionDuringPrompt.options,
|
|
411
|
+
});
|
|
412
|
+
writeFileSync(config.requestPermissionResultSink, JSON.stringify(result));
|
|
413
|
+
}
|
|
414
|
+
// Same idea for `elicitation/create`.
|
|
415
|
+
if (config.createElicitationDuringPrompt) {
|
|
416
|
+
const result = await ctx.client.request(methods.client.elicitation.create, config.createElicitationDuringPrompt);
|
|
417
|
+
writeFileSync(config.createElicitationResultSink, JSON.stringify(result));
|
|
418
|
+
}
|
|
419
|
+
if (config.hangUntilCancel) {
|
|
420
|
+
await cancelled;
|
|
421
|
+
return { stopReason: config.stopReasonAfterCancel ?? 'end_turn' };
|
|
422
|
+
}
|
|
423
|
+
return { stopReason };
|
|
424
|
+
});
|
|
425
|
+
// Mirrors the SDK's own `dist/examples/agent.js`: the agent's outgoing JSON-RPC traffic goes out
|
|
426
|
+
// over its own stdout, and it reads incoming traffic from its own stdin — the opposite way round
|
|
427
|
+
// from the client side of the same pipe pair.
|
|
428
|
+
const stream = ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
|
|
429
|
+
app.connect(stream);
|
|
430
|
+
}
|
|
431
|
+
main().catch((err) => {
|
|
432
|
+
process.stderr.write(`fake-agent crashed: ${(err && err.stack) || err}\n`);
|
|
433
|
+
process.exit(1);
|
|
434
|
+
});
|
|
435
|
+
export {};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACP's fifteen `SessionUpdate` variants, and one minimal (plus one "rich") input per variant.
|
|
3
|
+
*
|
|
4
|
+
* **This file exists because a hand-copied list of variants is exactly the guard that cannot
|
|
5
|
+
* fail.** The list used to live inline in `sessionUpdateMapper.test.ts` as a `const [...] as const`
|
|
6
|
+
* — its own comment said it was "the second, weaker net" — and `adoption.test.ts` shipped a stale
|
|
7
|
+
* copy of a different vocabulary list for a whole phase without anything noticing. So the list here
|
|
8
|
+
* is checked against the SDK's own type declaration ({@link acpVariantsFromSdk}) by
|
|
9
|
+
* `acpVocabulary.test.ts`, and its shape is pinned by the compiler as well:
|
|
10
|
+
* {@link MINIMAL} is a `Record` keyed on the SDK's own union, so adding, removing or misspelling a
|
|
11
|
+
* variant is a **compile** error before it is ever a test failure.
|
|
12
|
+
*
|
|
13
|
+
* Three suites share it — `sessionUpdateMapper.test.ts`, `acpVocabulary.test.ts` and
|
|
14
|
+
* `wireContract.test.ts` — so there is exactly one place where "what does ACP send" is written
|
|
15
|
+
* down, and it is the place the SDK is diffed against.
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { dirname, resolve } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
export const TEXT_CONTENT = { type: 'text', text: 'hello' };
|
|
21
|
+
/**
|
|
22
|
+
* The minimal fields each variant's own type requires.
|
|
23
|
+
*
|
|
24
|
+
* A `Record` over the SDK's union rather than a `switch`: a `switch` with a missing case is only a
|
|
25
|
+
* problem if some caller happens to hit it, while a `Record` with a missing key does not compile.
|
|
26
|
+
*/
|
|
27
|
+
const MINIMAL = {
|
|
28
|
+
user_message_chunk: { content: TEXT_CONTENT },
|
|
29
|
+
agent_message_chunk: { content: TEXT_CONTENT },
|
|
30
|
+
agent_thought_chunk: { content: TEXT_CONTENT },
|
|
31
|
+
tool_call: { toolCallId: 'call-1', title: 'Reading a file' },
|
|
32
|
+
tool_call_update: { toolCallId: 'call-1' },
|
|
33
|
+
plan: { entries: [] },
|
|
34
|
+
plan_update: { plan: { type: 'markdown', planId: 'plan-1', content: '- step one' } },
|
|
35
|
+
plan_removed: { planId: 'plan-1' },
|
|
36
|
+
available_commands_update: { availableCommands: [] },
|
|
37
|
+
current_mode_update: { currentModeId: 'default' },
|
|
38
|
+
config_option_update: { configOptions: [] },
|
|
39
|
+
session_info_update: {},
|
|
40
|
+
usage_update: { used: 100, size: 1000 },
|
|
41
|
+
compaction_update: { compactionId: 'compaction-1', status: 'in_progress' },
|
|
42
|
+
compaction_summary_chunk: { compactionId: 'compaction-1', content: TEXT_CONTENT },
|
|
43
|
+
};
|
|
44
|
+
/** Every ACP `SessionUpdate` variant, in the SDK's own declaration order. */
|
|
45
|
+
export const ALL_SESSION_UPDATE_VARIANTS = Object.keys(MINIMAL);
|
|
46
|
+
export function minimalFor(variant) {
|
|
47
|
+
return MINIMAL[variant];
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* "Rich" inputs for the two tool variants, whose mapping is the one place a `status` changes how
|
|
51
|
+
* many events come out: an already-terminal `tool_call` emits a second `tool_call_update` beside
|
|
52
|
+
* the creation, while a `tool_call_update` is always exactly one event whatever its status says
|
|
53
|
+
* (see `sessionUpdateMapper.ts`'s `toolCallUpdateEvent`). Kept separate from {@link minimalFor} so
|
|
54
|
+
* a sweep exercises both the pending/in_progress path and the completed/failed path for those two.
|
|
55
|
+
*/
|
|
56
|
+
export function richFor(variant) {
|
|
57
|
+
if (variant === 'tool_call') {
|
|
58
|
+
return [
|
|
59
|
+
{ toolCallId: 'call-1', title: 'Reading a file', status: 'completed', content: [{ type: 'content', content: TEXT_CONTENT }] },
|
|
60
|
+
{ toolCallId: 'call-1', title: 'Reading a file', status: 'failed', content: [{ type: 'content', content: TEXT_CONTENT }] },
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
if (variant === 'tool_call_update') {
|
|
64
|
+
return [
|
|
65
|
+
{ toolCallId: 'call-1', status: 'completed', content: [{ type: 'diff', path: '/repo/a.ts', newText: 'new' }] },
|
|
66
|
+
{ toolCallId: 'call-1', status: 'failed', content: [{ type: 'content', content: TEXT_CONTENT }] },
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
return [minimalFor(variant)];
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Every field the SDK declares on each variant, filled in — used by the wire-contract guard, whose
|
|
73
|
+
* question is "which keys can the mapper ever emit for this variant", not "does the minimal input
|
|
74
|
+
* work". A minimal input answers the second question only, and every optional field it leaves out
|
|
75
|
+
* is a key the guard would then never see.
|
|
76
|
+
*
|
|
77
|
+
* Hand-written rather than derived: the SDK ships `.d.ts`, not a runtime schema, so there is
|
|
78
|
+
* nothing to enumerate at runtime. What keeps it honest is the guard that consumes it — a key the
|
|
79
|
+
* mapper emits from a field missing here simply never appears, and the *other* direction (a Kotlin
|
|
80
|
+
* decoder reading a key nothing here produces) is what that guard reports.
|
|
81
|
+
*/
|
|
82
|
+
export function saturatedFor(variant) {
|
|
83
|
+
switch (variant) {
|
|
84
|
+
case 'user_message_chunk':
|
|
85
|
+
case 'agent_message_chunk':
|
|
86
|
+
case 'agent_thought_chunk':
|
|
87
|
+
// Task 8: `_meta` now rides all three chunk kinds too (`sessionUpdateMapper.ts`'s
|
|
88
|
+
// `mapSessionUpdate`), so the wire-contract guard (`wireContract.test.ts`) needs to see it
|
|
89
|
+
// emitted here or it flags `<event>._meta` as a key the server reads that the CLI never
|
|
90
|
+
// sends — a false "silent frame drop" for a key that is, in fact, sent.
|
|
91
|
+
return { content: TEXT_CONTENT, messageId: 'm-1', _meta: { claudeCode: { parentToolUseId: 'toolu_01' } } };
|
|
92
|
+
case 'tool_call':
|
|
93
|
+
// `tool_call` now has a named `TimelineEventWire.decodeToolCall` branch and its typed
|
|
94
|
+
// `WorkTimelinePayload.ToolCall` consumer preserves ACP's extension slot. Include `_meta`
|
|
95
|
+
// here so the cross-language guard exercises the actual transport contract rather than a
|
|
96
|
+
// pre-decoder fallback shape.
|
|
97
|
+
return {
|
|
98
|
+
toolCallId: 'call-1',
|
|
99
|
+
title: 'Reading a file',
|
|
100
|
+
name: 'read_file',
|
|
101
|
+
kind: 'read',
|
|
102
|
+
status: 'completed',
|
|
103
|
+
content: [{ type: 'content', content: TEXT_CONTENT }],
|
|
104
|
+
locations: [{ path: '/repo/a.ts', line: 3 }],
|
|
105
|
+
rawInput: { path: '/repo/a.ts' },
|
|
106
|
+
rawOutput: 'contents',
|
|
107
|
+
_meta: { claudeCode: { parentToolUseId: 'toolu_01' } },
|
|
108
|
+
};
|
|
109
|
+
case 'tool_call_update':
|
|
110
|
+
return {
|
|
111
|
+
toolCallId: 'call-1',
|
|
112
|
+
title: 'Reading a file',
|
|
113
|
+
name: 'read_file',
|
|
114
|
+
kind: 'read',
|
|
115
|
+
status: 'completed',
|
|
116
|
+
content: [{ type: 'content', content: TEXT_CONTENT }],
|
|
117
|
+
locations: [{ path: '/repo/a.ts', line: 3 }],
|
|
118
|
+
rawInput: { path: '/repo/a.ts' },
|
|
119
|
+
rawOutput: 'contents',
|
|
120
|
+
_meta: { claudeCode: { parentToolUseId: 'toolu_01' } },
|
|
121
|
+
};
|
|
122
|
+
case 'plan':
|
|
123
|
+
return { entries: [{ content: 'write the test', priority: 'medium', status: 'pending' }] };
|
|
124
|
+
case 'plan_update':
|
|
125
|
+
return { planId: 'plan-1', plan: { type: 'markdown', planId: 'plan-1', content: '- step one' } };
|
|
126
|
+
case 'plan_removed':
|
|
127
|
+
return { planId: 'plan-1' };
|
|
128
|
+
case 'available_commands_update':
|
|
129
|
+
return { availableCommands: [{ name: 'plan', description: 'Draft a plan', input: { hint: 'goal' } }] };
|
|
130
|
+
case 'current_mode_update':
|
|
131
|
+
return { currentModeId: 'default' };
|
|
132
|
+
case 'config_option_update':
|
|
133
|
+
return { configOptions: [{ id: 'model', name: 'Model', type: 'select', currentValue: 'opus', options: [] }] };
|
|
134
|
+
case 'session_info_update':
|
|
135
|
+
return { title: 'A session', updatedAt: '2026-08-24T00:00:00Z' };
|
|
136
|
+
case 'usage_update':
|
|
137
|
+
return { used: 100, size: 1000, cost: { amount: 0.5, currency: 'USD' } };
|
|
138
|
+
case 'compaction_update':
|
|
139
|
+
return { compactionId: 'compaction-1', status: 'failed', summary: [TEXT_CONTENT], error: 'out of budget' };
|
|
140
|
+
case 'compaction_summary_chunk':
|
|
141
|
+
return { compactionId: 'compaction-1', content: TEXT_CONTENT };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const SDK_TYPES = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../node_modules/@agentclientprotocol/sdk/dist/schema/types.gen.d.ts');
|
|
145
|
+
/**
|
|
146
|
+
* The variant tags the SDK itself declares, read out of `types.gen.d.ts`'s `SessionUpdate` union.
|
|
147
|
+
*
|
|
148
|
+
* Strict on purpose, in three places, because every one of them is a way for this to quietly stop
|
|
149
|
+
* comparing anything: the `export type SessionUpdate =` line must be found, the union must
|
|
150
|
+
* terminate on a `;`, and at least one tag must come out. A regex that silently matches nothing is
|
|
151
|
+
* precisely the unfalsifiable guard this whole file exists to replace.
|
|
152
|
+
*/
|
|
153
|
+
export function acpVariantsFromSdk() {
|
|
154
|
+
const source = readFileSync(SDK_TYPES, 'utf8');
|
|
155
|
+
const start = source.indexOf('export type SessionUpdate =');
|
|
156
|
+
if (start < 0)
|
|
157
|
+
throw new Error(`no \`export type SessionUpdate =\` in ${SDK_TYPES}`);
|
|
158
|
+
const end = source.indexOf('\n};', start);
|
|
159
|
+
if (end < 0)
|
|
160
|
+
throw new Error(`\`SessionUpdate\` union in ${SDK_TYPES} has no terminating \`\\n};\``);
|
|
161
|
+
const tags = [...source.slice(start, end).matchAll(/sessionUpdate:\s*"([^"]+)"/g)].map((m) => m[1]);
|
|
162
|
+
if (tags.length === 0)
|
|
163
|
+
throw new Error(`no \`sessionUpdate: "..."\` tags in ${SDK_TYPES}'s SessionUpdate union`);
|
|
164
|
+
return tags;
|
|
165
|
+
}
|