@raidiant/notifai 10.1.1 → 10.1.2
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/CHANGELOG.md +20 -0
- package/dist/commands-core.d.ts +1 -1
- package/dist/commands-init.js +9 -6
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.js +3 -0
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-skill.js +72 -54
- package/dist/commands-skill.js.map +1 -1
- package/dist/invocation-context.d.ts +1 -0
- package/dist/invocation-context.js +9 -1
- package/dist/invocation-context.js.map +1 -1
- package/dist/native-skills.d.ts +13 -3
- package/dist/native-skills.js +51 -12
- package/dist/native-skills.js.map +1 -1
- package/dist/node-floor.d.ts +2 -1
- package/dist/node-floor.js +9 -5
- package/dist/node-floor.js.map +1 -1
- package/dist/orca-session-title.d.ts +5 -4
- package/dist/orca-session-title.js +32 -19
- package/dist/orca-session-title.js.map +1 -1
- package/dist/platform.d.ts +11 -17
- package/dist/platform.js +20 -23
- package/dist/platform.js.map +1 -1
- package/dist/release.d.ts +5 -5
- package/dist/release.js +5 -5
- package/dist/setup-destinations.js +1 -1
- package/dist/setup-destinations.js.map +1 -1
- package/dist/skill-integrity.d.ts +49 -0
- package/dist/skill-integrity.js +154 -0
- package/dist/skill-integrity.js.map +1 -0
- package/dist/skill-source/manifest.json +16 -0
- package/dist/skill-source/notifai/SKILL.md +390 -0
- package/dist/skill-source/notifai/references/harness-setup.md +220 -0
- package/package.json +11 -14
- package/src/atomic-file.ts +0 -198
- package/src/claude-wake.test.ts +0 -321
- package/src/claude-wake.ts +0 -499
- package/src/cli-bin.test.ts +0 -176
- package/src/cli-bin.ts +0 -233
- package/src/cli-release.test.ts +0 -46
- package/src/cli-release.ts +0 -78
- package/src/cli-update-contract.ts +0 -9
- package/src/cli-update.test.ts +0 -125
- package/src/client.test.ts +0 -390
- package/src/client.ts +0 -352
- package/src/codex-wake.test.ts +0 -254
- package/src/codex-wake.ts +0 -301
- package/src/command-session.ts +0 -38
- package/src/commands-agent-sessions.ts +0 -72
- package/src/commands-auth.ts +0 -238
- package/src/commands-config.ts +0 -336
- package/src/commands-core.ts +0 -312
- package/src/commands-devices.ts +0 -180
- package/src/commands-doctor.ts +0 -1501
- package/src/commands-guidance.ts +0 -181
- package/src/commands-harness-context.ts +0 -207
- package/src/commands-hook-shape.ts +0 -69
- package/src/commands-hooks.ts +0 -2318
- package/src/commands-init.ts +0 -1055
- package/src/commands-io.ts +0 -108
- package/src/commands-logs.ts +0 -243
- package/src/commands-project.ts +0 -47
- package/src/commands-send-support.test.ts +0 -156
- package/src/commands-send-support.ts +0 -311
- package/src/commands-send.ts +0 -875
- package/src/commands-setup-proof.test.ts +0 -72
- package/src/commands-setup-proof.ts +0 -124
- package/src/commands-skill.test.ts +0 -44
- package/src/commands-skill.ts +0 -204
- package/src/commands-sounds.test.ts +0 -106
- package/src/commands-sounds.ts +0 -49
- package/src/commands-update.ts +0 -180
- package/src/commands.test.ts +0 -8297
- package/src/commands.ts +0 -77
- package/src/config-schema.test.ts +0 -153
- package/src/config-schema.ts +0 -349
- package/src/config.test.ts +0 -581
- package/src/config.ts +0 -475
- package/src/credentials.test.ts +0 -403
- package/src/credentials.ts +0 -403
- package/src/file-lock-process-worker.test.ts +0 -64
- package/src/file-lock.test.ts +0 -320
- package/src/file-lock.ts +0 -393
- package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
- package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
- package/src/generated-session-label.ts +0 -155
- package/src/guidance-content.ts +0 -278
- package/src/guidance-render.ts +0 -56
- package/src/guidance.test.ts +0 -327
- package/src/guidance.ts +0 -233
- package/src/harness-integration.test.ts +0 -294
- package/src/harnesses.test.ts +0 -149
- package/src/harnesses.ts +0 -164
- package/src/hook-adapter.test.ts +0 -496
- package/src/hook-adapter.ts +0 -424
- package/src/hook-input.test.ts +0 -39
- package/src/hook-input.ts +0 -72
- package/src/hooks.test.ts +0 -5824
- package/src/hooks.ts +0 -4011
- package/src/install-hooks.test.ts +0 -1614
- package/src/install-hooks.ts +0 -1501
- package/src/interactive.test.ts +0 -387
- package/src/interactive.ts +0 -858
- package/src/invocation-context.test.ts +0 -352
- package/src/invocation-context.ts +0 -197
- package/src/logging-process-worker.test.ts +0 -45
- package/src/logging.test.ts +0 -544
- package/src/logging.ts +0 -698
- package/src/main-run.ts +0 -45
- package/src/main.ts +0 -12
- package/src/native-skills.test.ts +0 -115
- package/src/native-skills.ts +0 -162
- package/src/node-floor.test.ts +0 -27
- package/src/node-floor.ts +0 -37
- package/src/onboarding-docs.test.ts +0 -25
- package/src/openclaw-plugin.ts +0 -409
- package/src/opencode-plugin.ts +0 -298
- package/src/orca-session-title.test.ts +0 -54
- package/src/orca-session-title.ts +0 -103
- package/src/pack-feedback-slice.test.ts +0 -212
- package/src/pack-feedback-slice.ts +0 -187
- package/src/platform.test.ts +0 -152
- package/src/platform.ts +0 -173
- package/src/program.test.ts +0 -317
- package/src/program.ts +0 -842
- package/src/project-enablement.test.ts +0 -55
- package/src/project-enablement.ts +0 -99
- package/src/question-caps.test.ts +0 -49
- package/src/question-settlement-process.ts +0 -47
- package/src/question-timing.ts +0 -56
- package/src/readiness.test.ts +0 -25
- package/src/readiness.ts +0 -212
- package/src/release.ts +0 -51
- package/src/send-attempts.test.ts +0 -143
- package/src/send-attempts.ts +0 -188
- package/src/send.test.ts +0 -28
- package/src/send.ts +0 -436
- package/src/session-activation.ts +0 -61
- package/src/session-end-log-lock-worker.test.ts +0 -35
- package/src/session-labels.test.ts +0 -490
- package/src/session-labels.ts +0 -474
- package/src/setup-destinations.test.ts +0 -41
- package/src/setup-destinations.ts +0 -50
- package/src/skill.test.ts +0 -426
- package/src/sound-ref.test.ts +0 -31
- package/src/sound-ref.ts +0 -34
- package/src/trust-policy.test.ts +0 -701
- package/src/ui/banner.ts +0 -95
- package/src/ui/config-view.test.ts +0 -90
- package/src/ui/config-view.ts +0 -138
- package/src/ui/help.test.ts +0 -16
- package/src/ui/help.ts +0 -128
- package/src/ui/theme.test.ts +0 -92
- package/src/ui/theme.ts +0 -265
- package/src/url-policy.ts +0 -399
- package/tsconfig.json +0 -10
package/src/config.test.ts
DELETED
|
@@ -1,581 +0,0 @@
|
|
|
1
|
-
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
|
|
2
|
-
import os from 'node:os'
|
|
3
|
-
import path from 'node:path'
|
|
4
|
-
import { afterAll, describe, expect, it } from 'vitest'
|
|
5
|
-
import { loadConfig, personalProjectConfigPath, sessionConfigPath } from './config.js'
|
|
6
|
-
import { buildDraft } from './send.js'
|
|
7
|
-
|
|
8
|
-
const tmp = mkdtempSync(path.join(os.tmpdir(), 'notifai-cli-'))
|
|
9
|
-
afterAll(() => rmSync(tmp, { recursive: true, force: true }))
|
|
10
|
-
|
|
11
|
-
function setup(options: {
|
|
12
|
-
globalToml?: string
|
|
13
|
-
projectToml?: string
|
|
14
|
-
projectLocalToml?: string
|
|
15
|
-
sessionToml?: string
|
|
16
|
-
sessionId?: string
|
|
17
|
-
}) {
|
|
18
|
-
const home = path.join(tmp, `case-${Math.random().toString(36).slice(2)}`)
|
|
19
|
-
const configDir = path.join(home, 'notifai')
|
|
20
|
-
mkdirSync(configDir, { recursive: true })
|
|
21
|
-
if (options.globalToml !== undefined) {
|
|
22
|
-
writeFileSync(path.join(configDir, 'config.toml'), options.globalToml)
|
|
23
|
-
}
|
|
24
|
-
const project = path.join(home, 'repo', 'nested')
|
|
25
|
-
mkdirSync(project, { recursive: true })
|
|
26
|
-
const projectDir = path.join(home, 'repo', '.notifai')
|
|
27
|
-
if (options.projectToml !== undefined) {
|
|
28
|
-
mkdirSync(projectDir, { recursive: true })
|
|
29
|
-
writeFileSync(path.join(projectDir, 'config.toml'), options.projectToml)
|
|
30
|
-
}
|
|
31
|
-
if (options.projectLocalToml !== undefined) {
|
|
32
|
-
const localFile = personalProjectConfigPath(project, { XDG_CONFIG_HOME: home })
|
|
33
|
-
mkdirSync(path.dirname(localFile), { recursive: true })
|
|
34
|
-
writeFileSync(localFile, options.projectLocalToml)
|
|
35
|
-
}
|
|
36
|
-
const state = path.join(home, 'state')
|
|
37
|
-
const env = { XDG_CONFIG_HOME: home, XDG_STATE_HOME: state } as NodeJS.ProcessEnv
|
|
38
|
-
if (options.sessionToml !== undefined) {
|
|
39
|
-
const file = sessionConfigPath(options.sessionId ?? 'sess', env)
|
|
40
|
-
mkdirSync(path.dirname(file), { recursive: true })
|
|
41
|
-
writeFileSync(file, options.sessionToml)
|
|
42
|
-
}
|
|
43
|
-
return { env, cwd: project }
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
describe('harness config layers', () => {
|
|
47
|
-
it('orders session over project-local over project over global', () => {
|
|
48
|
-
const { env, cwd } = setup({
|
|
49
|
-
globalToml: 'ask_grace_seconds = 10\n',
|
|
50
|
-
projectToml: 'ask_grace_seconds = 20\n',
|
|
51
|
-
projectLocalToml: 'ask_grace_seconds = 30\n',
|
|
52
|
-
sessionToml: 'ask_grace_seconds = 40\n',
|
|
53
|
-
sessionId: 'abc',
|
|
54
|
-
})
|
|
55
|
-
expect(loadConfig({ cwd, env, sessionId: 'abc' }).ask_grace_seconds).toMatchObject({
|
|
56
|
-
value: 40,
|
|
57
|
-
source: expect.stringMatching(/^session:/),
|
|
58
|
-
})
|
|
59
|
-
// Same files, different session: the session layer simply does not apply.
|
|
60
|
-
expect(loadConfig({ cwd, env, sessionId: 'other' }).ask_grace_seconds).toMatchObject({
|
|
61
|
-
value: 30,
|
|
62
|
-
source: expect.stringMatching(/^project-local:/),
|
|
63
|
-
})
|
|
64
|
-
expect(loadConfig({ cwd, env }).ask_grace_seconds.value).toBe(30)
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
it('keeps a personal toggle out of the committed project file', () => {
|
|
68
|
-
const { env, cwd } = setup({
|
|
69
|
-
projectToml: 'ask_notifications = true\n',
|
|
70
|
-
projectLocalToml: 'ask_notifications = false\n',
|
|
71
|
-
})
|
|
72
|
-
const config = loadConfig({ cwd, env })
|
|
73
|
-
expect(config.ask_notifications.value).toBe(false)
|
|
74
|
-
expect(config.ask_notifications.source).toMatch(/\/projects\/[0-9a-f]{32}\.toml$/)
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
it('ignores an in-tree config.local.toml leftover', () => {
|
|
78
|
-
const { env, cwd } = setup({ projectToml: 'ask_notifications = true\n' })
|
|
79
|
-
mkdirSync(path.join(cwd, '..', '.notifai'), { recursive: true })
|
|
80
|
-
writeFileSync(path.join(cwd, '..', '.notifai', 'config.local.toml'), 'ask_notifications = false\n')
|
|
81
|
-
expect(loadConfig({ cwd, env }).ask_notifications).toEqual({ value: true, source: expect.stringMatching(/^project:/) })
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
it('takes the service origin from NOTIFAI_BASE_URL, not from a config file', () => {
|
|
85
|
-
const { env, cwd } = setup({
|
|
86
|
-
projectToml: 'base_url = "https://attacker.example"\n',
|
|
87
|
-
})
|
|
88
|
-
expect(loadConfig({ cwd, env }).base_url).toEqual({
|
|
89
|
-
value: 'https://api.notifai.sh',
|
|
90
|
-
source: 'default',
|
|
91
|
-
})
|
|
92
|
-
expect(
|
|
93
|
-
loadConfig({ cwd, env: { ...env, NOTIFAI_BASE_URL: 'https://selfhost.example' } }).base_url,
|
|
94
|
-
).toEqual({ value: 'https://selfhost.example', source: 'env' })
|
|
95
|
-
expect(loadConfig({ cwd, env, flags: { base_url: 'https://flag.example' } }).base_url).toEqual({
|
|
96
|
-
value: 'https://flag.example',
|
|
97
|
-
source: 'flag',
|
|
98
|
-
})
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
it('uses NOTIFAI_SESSION_ID for the session layer and ignores the deleted env name', () => {
|
|
102
|
-
const { env, cwd } = setup({
|
|
103
|
-
sessionToml: 'ask_grace_seconds = 45\n',
|
|
104
|
-
sessionId: 'exact-session',
|
|
105
|
-
})
|
|
106
|
-
expect(
|
|
107
|
-
loadConfig({
|
|
108
|
-
cwd,
|
|
109
|
-
env: { ...env, NOTIFAI_SESSION_ID: 'exact-session' },
|
|
110
|
-
}).ask_grace_seconds.value,
|
|
111
|
-
).toBe(45)
|
|
112
|
-
expect(
|
|
113
|
-
loadConfig({
|
|
114
|
-
cwd,
|
|
115
|
-
env: { ...env, NOTIFAI_SESSION: 'exact-session' },
|
|
116
|
-
}).ask_grace_seconds.value,
|
|
117
|
-
).toBe(0)
|
|
118
|
-
})
|
|
119
|
-
|
|
120
|
-
it('keeps hostile or colliding session ids inside their own file', () => {
|
|
121
|
-
const { env, cwd } = setup({
|
|
122
|
-
sessionToml: 'ask_grace_seconds = 99\n',
|
|
123
|
-
sessionId: '../../etc/passwd',
|
|
124
|
-
})
|
|
125
|
-
// The id round-trips to its own state, and nothing escapes the directory.
|
|
126
|
-
expect(loadConfig({ cwd, env, sessionId: '../../etc/passwd' }).ask_grace_seconds.value).toBe(99)
|
|
127
|
-
expect(sessionConfigPath('../../etc/passwd', env)).toContain('/notifai/sessions/')
|
|
128
|
-
expect(sessionConfigPath('../../etc/passwd', env)).not.toContain('..')
|
|
129
|
-
// Ids that a sanitiser would have collapsed together must stay distinct:
|
|
130
|
-
// sharing a file meant sharing presence and pending questions.
|
|
131
|
-
expect(sessionConfigPath('a/b', env)).not.toBe(sessionConfigPath('a?b', env))
|
|
132
|
-
expect(loadConfig({ cwd, env, sessionId: 'a/b' }).ask_grace_seconds.value).toBe(0)
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
it('defaults question routing to on and reaching the user straight away', () => {
|
|
136
|
-
const { env, cwd } = setup({})
|
|
137
|
-
const config = loadConfig({ cwd, env })
|
|
138
|
-
expect(config.ask_notifications.value).toBe(true)
|
|
139
|
-
// Nothing here asks where the user is standing. A question goes out when
|
|
140
|
-
// the turn ends unless they choose a terminal-first window.
|
|
141
|
-
expect(config.ask_grace_seconds).toEqual({ value: 0, source: 'default' })
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
it('bounds the terminal-first window to the supported preference', () => {
|
|
145
|
-
// Config is readable from a repository, so an unbounded grace is hostile
|
|
146
|
-
// input rather than permission to hold question delivery arbitrarily.
|
|
147
|
-
const { env, cwd } = setup({ globalToml: 'ask_grace_seconds = 100000\n' })
|
|
148
|
-
expect(loadConfig({ cwd, env }).ask_grace_seconds.value).toBe(360)
|
|
149
|
-
})
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
describe('config precedence', () => {
|
|
153
|
-
it('defaults apply with provenance', () => {
|
|
154
|
-
const { env, cwd } = setup({})
|
|
155
|
-
const config = loadConfig({ cwd, env })
|
|
156
|
-
expect(config.wait_seconds.value).toBe(10)
|
|
157
|
-
expect(config.wait_seconds.source).toBe('default')
|
|
158
|
-
expect(config.ttl_seconds.value).toBe(86400)
|
|
159
|
-
})
|
|
160
|
-
|
|
161
|
-
it('project config (found by parent walk) beats machine-global', () => {
|
|
162
|
-
const { env, cwd } = setup({
|
|
163
|
-
globalToml: 'wait_seconds = 20\nsound = "default"\n',
|
|
164
|
-
projectToml: 'wait_seconds = 5\n',
|
|
165
|
-
})
|
|
166
|
-
const config = loadConfig({ cwd, env })
|
|
167
|
-
expect(config.wait_seconds.value).toBe(5)
|
|
168
|
-
expect(config.wait_seconds.source).toMatch(/^project:/)
|
|
169
|
-
expect(config.sound.value).toBe('default')
|
|
170
|
-
expect(config.sound.source).toMatch(/^global:/)
|
|
171
|
-
})
|
|
172
|
-
|
|
173
|
-
it('flags beat everything', () => {
|
|
174
|
-
const { env, cwd } = setup({ projectToml: 'wait_seconds = 5\n' })
|
|
175
|
-
const config = loadConfig({ cwd, env, flags: { wait_seconds: 2 } })
|
|
176
|
-
expect(config.wait_seconds.value).toBe(2)
|
|
177
|
-
expect(config.wait_seconds.source).toBe('flag')
|
|
178
|
-
})
|
|
179
|
-
|
|
180
|
-
it('uses the prior default when a newer CLI wrote an unknown interruption level', () => {
|
|
181
|
-
const { env, cwd } = setup({
|
|
182
|
-
globalToml: 'interruption_level = "future-level"\n',
|
|
183
|
-
})
|
|
184
|
-
const config = loadConfig({ cwd, env })
|
|
185
|
-
expect(config.interruption_level).toEqual({ value: null, source: 'default' })
|
|
186
|
-
})
|
|
187
|
-
|
|
188
|
-
it('keeps a custom sound name written by the user', () => {
|
|
189
|
-
const { env, cwd } = setup({
|
|
190
|
-
globalToml: 'sound = "Kitchen timer"\n',
|
|
191
|
-
})
|
|
192
|
-
const config = loadConfig({ cwd, env })
|
|
193
|
-
expect(config.sound).toEqual({ value: 'Kitchen timer', source: expect.stringMatching(/^global:/) })
|
|
194
|
-
})
|
|
195
|
-
|
|
196
|
-
it('drops a sound value that cannot be a name or id', () => {
|
|
197
|
-
const { env, cwd } = setup({
|
|
198
|
-
globalToml: `sound = "${'n'.repeat(200)}"\n`,
|
|
199
|
-
})
|
|
200
|
-
const config = loadConfig({ cwd, env })
|
|
201
|
-
expect(config.sound).toEqual({ value: null, source: 'default' })
|
|
202
|
-
})
|
|
203
|
-
})
|
|
204
|
-
|
|
205
|
-
describe('draft building', () => {
|
|
206
|
-
const base = setup({})
|
|
207
|
-
|
|
208
|
-
it('builds a minimal draft with defaults', () => {
|
|
209
|
-
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
210
|
-
const build = buildDraft(config, { title: 'T', body: 'B' })
|
|
211
|
-
if (!build.ok) throw new Error(build.error)
|
|
212
|
-
expect(build.draft).toEqual({
|
|
213
|
-
schema_version: 1,
|
|
214
|
-
presentation: { title: 'T', body: 'B' },
|
|
215
|
-
targets: { mode: 'all' },
|
|
216
|
-
delivery: { ttl_seconds: 86400, collapse_key: null },
|
|
217
|
-
})
|
|
218
|
-
})
|
|
219
|
-
|
|
220
|
-
it.each([
|
|
221
|
-
{ flags: {} },
|
|
222
|
-
{ flags: { kind: 'update' } },
|
|
223
|
-
{ flags: { kind: 'done' } },
|
|
224
|
-
{ flags: { kind: 'failed' } },
|
|
225
|
-
{ flags: { kind: 'blocked' } },
|
|
226
|
-
{ flags: { kind: 'question' } },
|
|
227
|
-
{ flags: { reply: true } },
|
|
228
|
-
])('omits sound from the draft for $flags so the server can resolve it', ({ flags }) => {
|
|
229
|
-
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
230
|
-
const build = buildDraft(config, { title: 'T', body: 'B', ...flags })
|
|
231
|
-
if (!build.ok) throw new Error(build.error)
|
|
232
|
-
expect(build.draft.platform?.ios?.sound).toBeUndefined()
|
|
233
|
-
expect(build.draft.platform?.android?.sound).toBeUndefined()
|
|
234
|
-
expect(build.draft.platform?.ios?.interruption_level).toBeUndefined()
|
|
235
|
-
})
|
|
236
|
-
|
|
237
|
-
it('lets an explicit sound outrank the kind it would have derived', () => {
|
|
238
|
-
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
239
|
-
const explicit = buildDraft(config, { title: 'T', body: 'B', kind: 'failed', sound: 'none' })
|
|
240
|
-
if (!explicit.ok) throw new Error(explicit.error)
|
|
241
|
-
expect(explicit.draft.platform?.ios?.sound).toBeNull()
|
|
242
|
-
})
|
|
243
|
-
|
|
244
|
-
it('orders explicit flags over saved user config', () => {
|
|
245
|
-
const { env, cwd } = setup({
|
|
246
|
-
projectToml: 'sound = "alert"\ninterruption_level = "active"\n',
|
|
247
|
-
})
|
|
248
|
-
const config = loadConfig({ cwd, env })
|
|
249
|
-
const configured = buildDraft(config, { title: 'T', body: 'B', kind: 'done' })
|
|
250
|
-
if (!configured.ok) throw new Error(configured.error)
|
|
251
|
-
expect(configured.draft.platform?.ios).toEqual({
|
|
252
|
-
sound: 'alert',
|
|
253
|
-
interruption_level: 'active',
|
|
254
|
-
})
|
|
255
|
-
expect(configured.draft.platform?.android).toEqual({ sound: 'alert' })
|
|
256
|
-
|
|
257
|
-
const explicit = buildDraft(config, {
|
|
258
|
-
title: 'T',
|
|
259
|
-
body: 'B',
|
|
260
|
-
kind: 'done',
|
|
261
|
-
sound: 'none',
|
|
262
|
-
level: 'time_sensitive',
|
|
263
|
-
})
|
|
264
|
-
if (!explicit.ok) throw new Error(explicit.error)
|
|
265
|
-
expect(explicit.draft.platform?.ios).toEqual({
|
|
266
|
-
sound: null,
|
|
267
|
-
interruption_level: 'time_sensitive',
|
|
268
|
-
})
|
|
269
|
-
expect(explicit.draft.platform?.android).toEqual({ sound: null })
|
|
270
|
-
})
|
|
271
|
-
|
|
272
|
-
it('keeps a question answerable for a day by default', () => {
|
|
273
|
-
// A question asked before someone steps away should still be theirs to
|
|
274
|
-
// answer when they come back; the CLI used to cut the wire contract's own
|
|
275
|
-
// default down to an hour.
|
|
276
|
-
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
277
|
-
const build = buildDraft(config, { title: 'Question', body: 'Deploy?', reply: true })
|
|
278
|
-
if (!build.ok) throw new Error(build.error)
|
|
279
|
-
|
|
280
|
-
expect(build.draft.reply?.expires_in_seconds).toBe(86_400)
|
|
281
|
-
})
|
|
282
|
-
|
|
283
|
-
it('lets the user shorten or lengthen how long an answer is accepted', () => {
|
|
284
|
-
const { env, cwd } = setup({ projectToml: 'reply_window_seconds = 7200\n' })
|
|
285
|
-
const config = loadConfig({ cwd, env })
|
|
286
|
-
const build = buildDraft(config, { title: 'Question', body: 'Deploy?', reply: true })
|
|
287
|
-
if (!build.ok) throw new Error(build.error)
|
|
288
|
-
expect(build.draft.reply?.expires_in_seconds).toBe(7_200)
|
|
289
|
-
|
|
290
|
-
const explicit = buildDraft(config, {
|
|
291
|
-
title: 'Question',
|
|
292
|
-
body: 'Deploy?',
|
|
293
|
-
reply: true,
|
|
294
|
-
replyWindow: 600,
|
|
295
|
-
})
|
|
296
|
-
if (!explicit.ok) throw new Error(explicit.error)
|
|
297
|
-
expect(explicit.draft.reply?.expires_in_seconds).toBe(600)
|
|
298
|
-
})
|
|
299
|
-
|
|
300
|
-
it('orders explicit and configured Project identity over inference', () => {
|
|
301
|
-
const { env, cwd } = setup({ projectToml: 'project = "my-app"\n' })
|
|
302
|
-
const config = loadConfig({ cwd, env })
|
|
303
|
-
const fromConfig = buildDraft(
|
|
304
|
-
config,
|
|
305
|
-
{ title: 'T', body: 'B' },
|
|
306
|
-
{ inferredProject: 'inferred-app' },
|
|
307
|
-
)
|
|
308
|
-
if (!fromConfig.ok) throw new Error(fromConfig.error)
|
|
309
|
-
expect(fromConfig.draft.project).toBe('my-app')
|
|
310
|
-
const fromFlag = buildDraft(
|
|
311
|
-
config,
|
|
312
|
-
{ title: 'T', body: 'B', project: 'other-app' },
|
|
313
|
-
{ inferredProject: 'inferred-app' },
|
|
314
|
-
)
|
|
315
|
-
if (!fromFlag.ok) throw new Error(fromFlag.error)
|
|
316
|
-
expect(fromFlag.draft.project).toBe('other-app')
|
|
317
|
-
|
|
318
|
-
const inferredCase = setup({})
|
|
319
|
-
const unset = loadConfig({ cwd: inferredCase.cwd, env: inferredCase.env })
|
|
320
|
-
const inferred = buildDraft(
|
|
321
|
-
unset,
|
|
322
|
-
{ title: 'T', body: 'B' },
|
|
323
|
-
{ inferredProject: 'inferred-app' },
|
|
324
|
-
)
|
|
325
|
-
if (!inferred.ok) throw new Error(inferred.error)
|
|
326
|
-
expect(inferred.draft.project).toBe('inferred-app')
|
|
327
|
-
})
|
|
328
|
-
|
|
329
|
-
it('carries Source Context without copying authoring overrides into legacy fields', () => {
|
|
330
|
-
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
331
|
-
const build = buildDraft(
|
|
332
|
-
config,
|
|
333
|
-
{ title: 'T', body: 'B', sessionId: 'opaque', sessionLabel: 'Visible' },
|
|
334
|
-
{
|
|
335
|
-
source: {
|
|
336
|
-
session_id: 'opaque',
|
|
337
|
-
session_label: 'Visible',
|
|
338
|
-
harness: 'codex',
|
|
339
|
-
branch: 'main',
|
|
340
|
-
worktree: 'topic',
|
|
341
|
-
},
|
|
342
|
-
},
|
|
343
|
-
)
|
|
344
|
-
if (!build.ok) throw new Error(build.error)
|
|
345
|
-
expect(build.draft.source).toEqual({
|
|
346
|
-
session_id: 'opaque',
|
|
347
|
-
session_label: 'Visible',
|
|
348
|
-
harness: 'codex',
|
|
349
|
-
branch: 'main',
|
|
350
|
-
worktree: 'topic',
|
|
351
|
-
})
|
|
352
|
-
expect(build.draft).not.toHaveProperty('session')
|
|
353
|
-
})
|
|
354
|
-
|
|
355
|
-
it('rewrites positional media references and preserves canonical ids', () => {
|
|
356
|
-
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
357
|
-
const build = buildDraft(config, {
|
|
358
|
-
title: 'Visual result',
|
|
359
|
-
body: ' then  and ',
|
|
360
|
-
image: ['med_first', 'med_second', 'med_attached'],
|
|
361
|
-
imageAlt: ['First image', 'Second image'],
|
|
362
|
-
})
|
|
363
|
-
if (!build.ok) throw new Error(build.error)
|
|
364
|
-
expect(build.draft.presentation.body).toBe(
|
|
365
|
-
' then  and ',
|
|
366
|
-
)
|
|
367
|
-
expect(build.draft.presentation.body).not.toMatch(/media:[1-8](?!\d)/)
|
|
368
|
-
expect(build.draft.presentation.media).toEqual([
|
|
369
|
-
{ media_id: 'med_first', alt: 'First image' },
|
|
370
|
-
{ media_id: 'med_second', alt: 'Second image' },
|
|
371
|
-
{ media_id: 'med_attached' },
|
|
372
|
-
])
|
|
373
|
-
})
|
|
374
|
-
|
|
375
|
-
it('preserves sparse alt text in an internal ordered media manifest', () => {
|
|
376
|
-
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
377
|
-
const build = buildDraft(config, {
|
|
378
|
-
title: 'Visual result',
|
|
379
|
-
body: '',
|
|
380
|
-
media: [
|
|
381
|
-
{ media_id: 'med_first' },
|
|
382
|
-
{ media_id: 'med_second', alt: 'Second image' },
|
|
383
|
-
],
|
|
384
|
-
})
|
|
385
|
-
if (!build.ok) throw new Error(build.error)
|
|
386
|
-
expect(build.draft.presentation.body).toBe('')
|
|
387
|
-
expect(build.draft.presentation.media).toEqual([
|
|
388
|
-
{ media_id: 'med_first' },
|
|
389
|
-
{ media_id: 'med_second', alt: 'Second image' },
|
|
390
|
-
])
|
|
391
|
-
})
|
|
392
|
-
|
|
393
|
-
it('names an out-of-range positional media reference', () => {
|
|
394
|
-
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
395
|
-
const build = buildDraft(config, {
|
|
396
|
-
title: 'Visual result',
|
|
397
|
-
body: '',
|
|
398
|
-
image: ['med_first', 'med_second'],
|
|
399
|
-
})
|
|
400
|
-
expect(build).toEqual({
|
|
401
|
-
ok: false,
|
|
402
|
-
error: 'Body reference "media:3" has no matching --image occurrence.',
|
|
403
|
-
})
|
|
404
|
-
})
|
|
405
|
-
|
|
406
|
-
it('validates media cardinality and alt pairing', () => {
|
|
407
|
-
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
408
|
-
expect(
|
|
409
|
-
buildDraft(config, {
|
|
410
|
-
title: 'T',
|
|
411
|
-
body: 'B',
|
|
412
|
-
image: Array.from({ length: 9 }, (_, index) => `med_${index}`),
|
|
413
|
-
}),
|
|
414
|
-
).toMatchObject({ ok: false, error: expect.stringContaining('at most 8') })
|
|
415
|
-
expect(
|
|
416
|
-
buildDraft(config, {
|
|
417
|
-
title: 'T',
|
|
418
|
-
body: 'B',
|
|
419
|
-
image: ['med_one'],
|
|
420
|
-
imageAlt: ['one', 'two'],
|
|
421
|
-
}),
|
|
422
|
-
).toMatchObject({ ok: false, error: expect.stringContaining('2 --image-alt') })
|
|
423
|
-
})
|
|
424
|
-
|
|
425
|
-
it('carries --kind and rejects one outside the vocabulary', () => {
|
|
426
|
-
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
427
|
-
const done = buildDraft(config, { title: 'T', body: 'B', kind: 'done' })
|
|
428
|
-
if (!done.ok) throw new Error(done.error)
|
|
429
|
-
expect(done.draft.kind).toBe('done')
|
|
430
|
-
// Absent stays absent rather than becoming an explicit 'update': the
|
|
431
|
-
// server reads a missing field as the default, and the envelope has 4096
|
|
432
|
-
// bytes to spend on things that say something.
|
|
433
|
-
const plain = buildDraft(config, { title: 'T', body: 'B' })
|
|
434
|
-
if (!plain.ok) throw new Error(plain.error)
|
|
435
|
-
expect(plain.draft.kind).toBeUndefined()
|
|
436
|
-
// Caught in the CLI so the agent gets the list, not a schema error from
|
|
437
|
-
// the server after a round trip.
|
|
438
|
-
const bad = buildDraft(config, { title: 'T', body: 'B', kind: 'finished' })
|
|
439
|
-
expect(bad.ok).toBe(false)
|
|
440
|
-
if (bad.ok) throw new Error('expected rejection')
|
|
441
|
-
expect(bad.error).toContain('"done"')
|
|
442
|
-
})
|
|
443
|
-
|
|
444
|
-
it('uses configured devices unless --all is passed', () => {
|
|
445
|
-
const { env, cwd } = setup({ projectToml: 'devices = ["dev_a", "dev_b"]\n' })
|
|
446
|
-
const config = loadConfig({ cwd, env })
|
|
447
|
-
const scoped = buildDraft(config, { title: 'T', body: 'B' })
|
|
448
|
-
if (!scoped.ok) throw new Error(scoped.error)
|
|
449
|
-
expect(scoped.draft.targets).toEqual({ mode: 'selected', device_ids: ['dev_a', 'dev_b'] })
|
|
450
|
-
const all = buildDraft(config, { title: 'T', body: 'B', all: true })
|
|
451
|
-
if (!all.ok) throw new Error(all.error)
|
|
452
|
-
expect(all.draft.targets).toEqual({ mode: 'all' })
|
|
453
|
-
})
|
|
454
|
-
|
|
455
|
-
it('maps sound none to explicit null and parses custom data', () => {
|
|
456
|
-
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
457
|
-
const build = buildDraft(config, {
|
|
458
|
-
title: 'T',
|
|
459
|
-
body: 'B',
|
|
460
|
-
sound: 'none',
|
|
461
|
-
level: 'time_sensitive',
|
|
462
|
-
data: ['run_id=42', 'branch=main'],
|
|
463
|
-
})
|
|
464
|
-
if (!build.ok) throw new Error(build.error)
|
|
465
|
-
expect(build.draft.platform?.ios).toEqual({
|
|
466
|
-
sound: null,
|
|
467
|
-
interruption_level: 'time_sensitive',
|
|
468
|
-
custom_data: { run_id: '42', branch: 'main' },
|
|
469
|
-
})
|
|
470
|
-
expect(build.draft.platform?.macos).toEqual(build.draft.platform?.ios)
|
|
471
|
-
expect(build.draft.platform?.android).toEqual({
|
|
472
|
-
sound: null,
|
|
473
|
-
custom_data: { run_id: '42', branch: 'main' },
|
|
474
|
-
})
|
|
475
|
-
})
|
|
476
|
-
|
|
477
|
-
it('maps optional fields into the selected macOS platform slot', () => {
|
|
478
|
-
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
479
|
-
const build = buildDraft(config, {
|
|
480
|
-
title: 'T',
|
|
481
|
-
body: 'B',
|
|
482
|
-
platform: 'macos',
|
|
483
|
-
sound: 'none',
|
|
484
|
-
threadId: 'desktop-builds',
|
|
485
|
-
level: 'passive',
|
|
486
|
-
data: ['run_id=42'],
|
|
487
|
-
})
|
|
488
|
-
if (!build.ok) throw new Error(build.error)
|
|
489
|
-
|
|
490
|
-
expect(build.platform).toBe('macos')
|
|
491
|
-
expect(build.draft.platform).toEqual({
|
|
492
|
-
macos: {
|
|
493
|
-
sound: null,
|
|
494
|
-
thread_id: 'desktop-builds',
|
|
495
|
-
interruption_level: 'passive',
|
|
496
|
-
custom_data: { run_id: '42' },
|
|
497
|
-
},
|
|
498
|
-
})
|
|
499
|
-
})
|
|
500
|
-
|
|
501
|
-
it('maps supported optional fields into the selected Android platform slot', () => {
|
|
502
|
-
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
503
|
-
const build = buildDraft(config, {
|
|
504
|
-
title: 'T',
|
|
505
|
-
body: 'B',
|
|
506
|
-
platform: 'android',
|
|
507
|
-
sound: 'none',
|
|
508
|
-
threadId: 'mobile-builds',
|
|
509
|
-
data: ['run_id=42'],
|
|
510
|
-
})
|
|
511
|
-
if (!build.ok) throw new Error(build.error)
|
|
512
|
-
|
|
513
|
-
expect(build.platform).toBe('android')
|
|
514
|
-
expect(build.draft.platform).toEqual({
|
|
515
|
-
android: {
|
|
516
|
-
sound: null,
|
|
517
|
-
thread_id: 'mobile-builds',
|
|
518
|
-
custom_data: { run_id: '42' },
|
|
519
|
-
},
|
|
520
|
-
})
|
|
521
|
-
})
|
|
522
|
-
|
|
523
|
-
it('rejects explicit or configured interruption levels for Android', () => {
|
|
524
|
-
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
525
|
-
expect(
|
|
526
|
-
buildDraft(config, {
|
|
527
|
-
title: 'T',
|
|
528
|
-
body: 'B',
|
|
529
|
-
platform: 'android',
|
|
530
|
-
level: 'active',
|
|
531
|
-
}),
|
|
532
|
-
).toMatchObject({
|
|
533
|
-
ok: false,
|
|
534
|
-
error: expect.stringContaining('Android does not support caller-selected interruption levels'),
|
|
535
|
-
})
|
|
536
|
-
|
|
537
|
-
const configuredCase = setup({ projectToml: 'interruption_level = "passive"\n' })
|
|
538
|
-
const configured = loadConfig({ cwd: configuredCase.cwd, env: configuredCase.env })
|
|
539
|
-
expect(buildDraft(configured, { title: 'T', body: 'B', platform: 'android' })).toMatchObject({
|
|
540
|
-
ok: false,
|
|
541
|
-
error: expect.stringContaining('unset the configured interruption_level'),
|
|
542
|
-
})
|
|
543
|
-
})
|
|
544
|
-
|
|
545
|
-
it('limits collapse keys by UTF-8 bytes', () => {
|
|
546
|
-
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
547
|
-
expect(buildDraft(config, { title: 'T', body: 'B', collapseKey: '😀'.repeat(16) }).ok).toBe(
|
|
548
|
-
true,
|
|
549
|
-
)
|
|
550
|
-
const oversized = buildDraft(config, {
|
|
551
|
-
title: 'T',
|
|
552
|
-
body: 'B',
|
|
553
|
-
collapseKey: '😀'.repeat(17),
|
|
554
|
-
})
|
|
555
|
-
expect(oversized.ok).toBe(false)
|
|
556
|
-
if (oversized.ok) throw new Error('expected rejection')
|
|
557
|
-
expect(oversized.error).toContain('64 UTF-8 bytes')
|
|
558
|
-
})
|
|
559
|
-
|
|
560
|
-
it('rejects malformed inputs with usage errors', () => {
|
|
561
|
-
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
562
|
-
expect(buildDraft(config, { title: '', body: 'B' }).ok).toBe(false)
|
|
563
|
-
expect(buildDraft(config, { title: 'T', body: 'B', sound: 'n'.repeat(200) }).ok).toBe(false)
|
|
564
|
-
expect(buildDraft(config, { title: 'T', body: 'B', data: ['nokey'] }).ok).toBe(false)
|
|
565
|
-
expect(buildDraft(config, { title: 'T', body: 'B', level: 'shouting' }).ok).toBe(false)
|
|
566
|
-
expect(buildDraft(config, { title: 'T', body: 'B', platform: 'linux' }).ok).toBe(false)
|
|
567
|
-
})
|
|
568
|
-
|
|
569
|
-
it('puts a custom sound name on the draft for the server to resolve', () => {
|
|
570
|
-
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
571
|
-
const byName = buildDraft(config, { title: 'T', body: 'B', sound: 'airhorn' })
|
|
572
|
-
if (!byName.ok) throw new Error(byName.error)
|
|
573
|
-
expect(byName.draft.platform?.ios?.sound).toBe('airhorn')
|
|
574
|
-
expect(byName.draft.platform?.android?.sound).toBe('airhorn')
|
|
575
|
-
|
|
576
|
-
const byId = buildDraft(config, { title: 'T', body: 'B', sound: 'snd_01HQTEST' })
|
|
577
|
-
if (!byId.ok) throw new Error(byId.error)
|
|
578
|
-
expect(byId.draft.platform?.ios?.sound).toBe('snd_01HQTEST')
|
|
579
|
-
expect(byId.draft.platform?.android?.sound).toBe('snd_01HQTEST')
|
|
580
|
-
})
|
|
581
|
-
})
|