clauderipple 0.8.9 → 0.9.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/CHANGELOG.md +62 -0
- package/README.es.md +0 -4
- package/README.ja.md +0 -4
- package/README.ko.md +5 -1
- package/README.md +5 -1
- package/README.pt-BR.md +0 -4
- package/README.zh-CN.md +0 -4
- package/dist/app/dist/main.js +23 -3
- package/dist/app/package.json +1 -1
- package/dist/cli/src/codex.js +3 -1
- package/dist/cli/src/index.js +1 -1
- package/dist/cli/src/settings.js +5 -2
- package/dist/cli/src/tray.js +41 -5
- package/dist/mod/.claude-plugin/plugin.json +9 -1
- package/dist/mod/hooks/cache.ts +60 -0
- package/dist/mod/hooks/compact.ts +40 -0
- package/dist/mod/hooks/handoff.ts +43 -0
- package/dist/mod/hooks/i18n.ts +2716 -0
- package/dist/mod/hooks/judge.ts +226 -0
- package/dist/mod/hooks/ladder.js +179 -0
- package/dist/mod/hooks/ladder.ts +174 -0
- package/dist/mod/hooks/modes.ts +93 -0
- package/dist/mod/hooks/register.tsx +1458 -256
- package/dist/mod/hooks/session.ts +62 -0
- package/dist/mod/hooks/settings.js +342 -0
- package/dist/mod/hooks/settings.ts +321 -0
- package/dist/mod/hooks/stats.ts +59 -0
- package/dist/mod/hooks/ui/agents.tsx +138 -0
- package/dist/mod/hooks/ui/alerts.tsx +370 -0
- package/dist/mod/hooks/ui/art.ts +628 -0
- package/dist/mod/hooks/ui/band.tsx +836 -0
- package/dist/mod/hooks/ui/kit.tsx +258 -0
- package/dist/mod/hooks/ui/log.tsx +109 -0
- package/dist/mod/hooks/ui/parts.tsx +217 -0
- package/dist/mod/hooks/ui/ripple.ts +614 -0
- package/dist/mod/hooks/ui/settings.tsx +376 -0
- package/dist/mod/hooks/ui/setup.tsx +148 -0
- package/dist/mod/hooks/ui/theme.ts +217 -0
- package/dist/mod/hooks/ui/view.ts +371 -0
- package/dist/mod/hooks/worker.ts +1 -1
- package/dist/mod/hooks/workers.ts +217 -0
- package/dist/mod/tests/register.test.tsx +1730 -0
- package/dist/mod/tests/ui.test.tsx +853 -0
- package/dist/mod/tsconfig.json +2 -1
- package/dist/mod/types/index.d.ts +320 -1
- package/dist/router/src/admin.js +195 -53
- package/dist/router/src/agents.js +24 -5
- package/dist/router/src/bootstrap.js +4 -2
- package/dist/router/src/compat.js +10 -2
- package/dist/router/src/config.js +3 -0
- package/dist/router/src/health.js +33 -0
- package/dist/router/src/index.js +1 -0
- package/dist/router/src/ingress/models.js +4 -1
- package/dist/router/src/mod-settings.js +119 -0
- package/dist/router/src/model-policy.js +6 -0
- package/dist/router/src/model-scores.js +159 -0
- package/dist/router/src/picker.js +7 -0
- package/dist/router/src/providers/anthropic-account-pool.js +10 -4
- package/dist/router/src/providers/anthropic-accounts.js +26 -1
- package/dist/router/src/providers/chatgpt/catalog.js +45 -14
- package/dist/router/src/providers/chatgpt/index.js +9 -3
- package/dist/router/src/providers/chatgpt/translate.js +9 -5
- package/dist/router/src/providers/google/index.js +6 -2
- package/dist/router/src/providers/openai/index.js +4 -1
- package/dist/router/src/providers/openai/translate.js +1 -1
- package/dist/router/src/proxy.js +14 -3
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +932 -209
- package/dist/ui/favicon-32.png +0 -0
- package/dist/ui/favicon.svg +12 -0
- package/dist/ui/i18n.js +923 -237
- package/dist/ui/index.html +159 -87
- package/dist/ui/style.css +789 -408
- package/docs/ARCHITECTURE.md +134 -2
- package/package.json +7 -3
|
@@ -0,0 +1,1730 @@
|
|
|
1
|
+
// Integration through the actual mods engine. Only external host boundaries are stubbed.
|
|
2
|
+
import { expect, mock, test } from 'claude-code/testing'
|
|
3
|
+
import type { Engine } from 'claude-code/testing'
|
|
4
|
+
import type { AgentSpawnInput, ModelCompleteRequest, ModelUsage, On, SessionMessage, ToolCallInput, TurnStepInput } from 'claude-code'
|
|
5
|
+
import { MOD_SETTINGS_SCHEMA_VERSION, DEFAULTS, normalizeModes, normalizeSettings } from '../hooks/settings'
|
|
6
|
+
import type { Lang } from '../hooks/i18n'
|
|
7
|
+
import type { ModSettingsSnapshot, Settings } from '../types'
|
|
8
|
+
|
|
9
|
+
const NOW = 1000000
|
|
10
|
+
const USAGE: ModelUsage = { input_tokens: 100, output_tokens: 10, cache_read_input_tokens: 40000, cache_creation_input_tokens: 0 }
|
|
11
|
+
const FILES: Record<string, string> = {
|
|
12
|
+
'deepseek-v4-1-flash': '---\nname: deepseek-v4-1-flash\ndescription: Executor via deepseek.\nmodel: deepseek-v4.1-flash@medium\n---\nExecute.',
|
|
13
|
+
'gpt-6-1-sol': '---\nname: gpt-6-1-sol\ndescription: Executor via chatgpt.\nmodel: gpt-6.1-sol@medium\n---\nExecute.',
|
|
14
|
+
'gpt-6-astra': '---\nname: gpt-6-astra\ndescription: Executor via chatgpt.\nmodel: gpt-6-astra@high\n---\nExecute.',
|
|
15
|
+
}
|
|
16
|
+
const AA = { models: [
|
|
17
|
+
{ model: 'gpt-6.1-sol', provider: 'chatgpt', variants: [{ effort: 'high', intelligence: 50.24, costPerTask: 0.3191 }] },
|
|
18
|
+
{ model: 'gpt-6-astra', provider: 'chatgpt', variants: [{ effort: 'medium', intelligence: 49.6, costPerTask: 1.541 }] },
|
|
19
|
+
{ model: 'claude-sonnet-5-5', provider: 'anthropic', variants: [{ effort: 'high', intelligence: 46.8, costPerTask: 0.884 }] },
|
|
20
|
+
{ model: 'deepseek-v4.1-flash', provider: 'deepseek', variants: [{ effort: 'max', intelligence: 39.46, costPerTask: 0.2652 }] },
|
|
21
|
+
] }
|
|
22
|
+
// GET /api/mod-options as a router of 2026-10-11 answers it: workers with ladders and billing.
|
|
23
|
+
const MOD_OPTIONS = {
|
|
24
|
+
agents: [
|
|
25
|
+
{ name: 'deepseek-v4-1-flash', model: 'deepseek-v4.1-flash', provider: 'deepseek', efforts: ['low', 'high', 'max'], billing: 'payg' },
|
|
26
|
+
{ name: 'gpt-6-1-sol', model: 'gpt-6.1-sol', provider: 'chatgpt', efforts: ['low', 'medium', 'high', 'xhigh', 'max'], billing: 'subscription' },
|
|
27
|
+
{ name: 'gpt-6-astra', model: 'gpt-6-astra', provider: 'chatgpt', efforts: ['low', 'medium', 'high', 'xhigh', 'max'], billing: 'subscription' },
|
|
28
|
+
],
|
|
29
|
+
providers: [{ name: 'deepseek', type: 'anthropic-compatible', usable: true, billing: 'payg' }, { name: 'chatgpt', type: 'chatgpt', usable: true, billing: 'subscription' }],
|
|
30
|
+
claudeEfforts: ['low', 'medium', 'high', 'max'],
|
|
31
|
+
scores: AA,
|
|
32
|
+
}
|
|
33
|
+
const CONFIG = { admin: { port: 9992 }, providers: { deepseek: { models: [{ id: 'deepseek-v4.1-flash' }] }, chatgpt: { models: [{ id: 'gpt-6.1-sol' }, { id: 'gpt-6-astra' }] }, anthropic: { models: [{ id: 'claude-sonnet-5-5' }] } } }
|
|
34
|
+
const SPAWN: AgentSpawnInput = { tool_use_id: 'tool1', subagentType: 'ripple:auto', prompt: 'Rename one local variable and verify it.', description: 'Rename variable', parentModel: 'claude-opus-5-5', provider: { plugin: 'engine', tier: 'core' }, background: false, fork: false }
|
|
35
|
+
const MESSAGES: SessionMessage[] = [{ role: 'user', text: 'Implement the specified change and verify it.', toolUses: [] }, { role: 'assistant', text: 'The implementation is ready.', toolUses: [] }]
|
|
36
|
+
|
|
37
|
+
function boundaries(on: On, patch: Partial<Settings> = {}, options: { config?: unknown; paths?: string[]; key?: string; envFile?: string | (() => string); language?: unknown; env?: Record<string, string>; platform?: string; browserFailure?: 'exit' | 'throw'; appleLocale?: string; appleFail?: boolean; limits?: { kind: string; percentUsed: number; resetsAt?: string }[] } = {}) {
|
|
38
|
+
const clock = mock.clock(on, { now: NOW })
|
|
39
|
+
const saved = new Map<string, unknown>()
|
|
40
|
+
const storeWrites: string[] = []
|
|
41
|
+
const storeReads: string[] = []
|
|
42
|
+
let router: ModSettingsSnapshot = { settings: normalizeSettings({ setupDone: true, ...patch }), revision: 1, stored: true }
|
|
43
|
+
let offline = false
|
|
44
|
+
let conflict: Settings | null = null
|
|
45
|
+
let alwaysConflict = false
|
|
46
|
+
const settingsRequests: { method: string; settings?: unknown; revision?: unknown; schemaVersion?: unknown }[] = []
|
|
47
|
+
on('http.fetch', { url: /\/api\/mod-settings$/ }, ($, e) => {
|
|
48
|
+
if (offline) throw new Error('router unavailable')
|
|
49
|
+
const method = e.init?.method ?? 'GET'
|
|
50
|
+
if (method === 'GET') {
|
|
51
|
+
settingsRequests.push({ method })
|
|
52
|
+
return { value: { ok: true, status: 200, headers: {}, text: JSON.stringify(router) } }
|
|
53
|
+
}
|
|
54
|
+
const input = JSON.parse(e.init?.body ?? '{}')
|
|
55
|
+
settingsRequests.push({ method, settings: input.settings, revision: input.revision, schemaVersion: input.schemaVersion })
|
|
56
|
+
if (conflict) { router = { settings: conflict, revision: router.revision + 1, stored: true }; conflict = null }
|
|
57
|
+
if (alwaysConflict) router = { ...router, revision: router.revision + 1 }
|
|
58
|
+
const stale = input.revision !== router.revision
|
|
59
|
+
if (!stale) router = { settings: normalizeSettings(input.settings), revision: router.revision + 1, stored: true }
|
|
60
|
+
return { value: { ok: !stale, status: stale ? 409 : 200, headers: {}, text: JSON.stringify(router) } }
|
|
61
|
+
})
|
|
62
|
+
const toasts: string[] = []
|
|
63
|
+
const logs: string[] = []
|
|
64
|
+
const submitted: string[] = []
|
|
65
|
+
const filled: string[] = []
|
|
66
|
+
const panes = new Set<string>()
|
|
67
|
+
let model = 'claude-opus-5-5'
|
|
68
|
+
let percent = 20
|
|
69
|
+
on('store.get', ($, e) => { storeReads.push(e.key); return { value: saved.get(e.key) } })
|
|
70
|
+
on('store.set', ($, e) => { storeWrites.push(e.key); saved.set(e.key, e.value); return { value: undefined } })
|
|
71
|
+
mock.env(on, { HOME: '/home/test', LANG: 'en_US.UTF-8', ...(options.key ? { TYPESAFE_API_KEY: options.key } : {}), ...options.env })
|
|
72
|
+
let hostLanguage = options.language
|
|
73
|
+
const processes: { argv: readonly string[]; timeoutMs?: number }[] = []
|
|
74
|
+
on('settings.read', () => ({ value: { language: hostLanguage } }))
|
|
75
|
+
let resolvedLanguage: unknown
|
|
76
|
+
on('state.set', { plugin: 'ripple', key: 'lang' }, async ($, e, next) => {
|
|
77
|
+
const result = await next(e)
|
|
78
|
+
if ('value' in result && result.value?.isSet) resolvedLanguage = e.value
|
|
79
|
+
return result
|
|
80
|
+
})
|
|
81
|
+
on('session.cwd', () => ({ value: '/work' }))
|
|
82
|
+
on('process.run', ($, e) => {
|
|
83
|
+
processes.push({ argv: e.argv, timeoutMs: e.init?.timeoutMs })
|
|
84
|
+
if (['open', 'xdg-open', 'cmd'].includes(e.argv[0] ?? '') && options.browserFailure) {
|
|
85
|
+
if (options.browserFailure === 'throw') throw new Error('browser unavailable')
|
|
86
|
+
return { value: { exitCode: 1, stdout: '', stderr: 'browser unavailable', isStdoutTruncated: false, isStderrTruncated: false } }
|
|
87
|
+
}
|
|
88
|
+
if (e.argv[0] === 'uname') return { value: { exitCode: 0, stdout: options.platform ?? 'Linux', stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
|
|
89
|
+
if (e.argv[0] === 'defaults' && options.appleFail) return { value: { exitCode: 1, stdout: '', stderr: 'locale timed out', isStdoutTruncated: false, isStderrTruncated: false } }
|
|
90
|
+
return { value: { exitCode: 0, stdout: options.appleLocale ?? '', stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
|
|
91
|
+
})
|
|
92
|
+
const files = new Map<string, string>()
|
|
93
|
+
let logFailure: 'read' | 'write' | null = null
|
|
94
|
+
on('fs.exists', ($, e) => ({ value: files.has(e.path) }))
|
|
95
|
+
on('fs.write', ($, e) => {
|
|
96
|
+
if (e.path.endsWith('/worker-picks.jsonl') && logFailure === 'write') return { deny: 'worker log write denied' }
|
|
97
|
+
files.set(e.path, e.text)
|
|
98
|
+
return { value: undefined }
|
|
99
|
+
})
|
|
100
|
+
on('fs.read', ($, e) => {
|
|
101
|
+
options.paths?.push(e.path)
|
|
102
|
+
if (e.path.endsWith('/worker-picks.jsonl') && logFailure === 'read') return { deny: 'worker log read denied' }
|
|
103
|
+
if (files.has(e.path)) return { value: files.get(e.path)! }
|
|
104
|
+
if (e.path.endsWith('generated-agents.json')) return { value: JSON.stringify({ agents: [...Object.keys(FILES), 'claude-sonnet-5-5'] }) }
|
|
105
|
+
if (e.path.endsWith('/claude-sonnet-5-5.md')) return { value: '---\nname: claude-sonnet-5-5\ndescription: Execute via anthropic.\nmodel: claude-sonnet-5-5@high\n---\nExecute.' }
|
|
106
|
+
if (e.path.endsWith('config.json')) return { value: JSON.stringify(options.config ?? CONFIG) }
|
|
107
|
+
if (e.path.endsWith('/.config/jev/.env')) return { value: typeof options.envFile === 'function' ? options.envFile() : options.envFile ?? '' }
|
|
108
|
+
const name = e.path.split('/').pop()?.replace(/\.md$/, '') ?? ''
|
|
109
|
+
return { value: FILES[name] ?? '' }
|
|
110
|
+
})
|
|
111
|
+
on('session.start', () => ({ cwd: '/work' }))
|
|
112
|
+
on('classic.SessionStart', () => ({}))
|
|
113
|
+
on('command.register', ($, e) => ({ value: { command: e.name } }))
|
|
114
|
+
const registered: { name: string; prompt: string; disallowedTools?: readonly string[]; description: string }[] = []
|
|
115
|
+
on('agent.register', ($, e) => { registered.push(e); return { value: { agent: 'ripple:' + e.name } } })
|
|
116
|
+
on('command.list', () => ({ value: [{ name: 'clear', description: 'Clear', source: 'builtin' }, { name: 'compact', description: 'Compact', source: 'builtin' }, { name: 'model', description: 'Model', source: 'builtin' }] }))
|
|
117
|
+
on('session.id', () => ({ value: 'session-test' }))
|
|
118
|
+
on('session.model', () => ({ value: model }))
|
|
119
|
+
on('session.messages', () => ({ value: MESSAGES }))
|
|
120
|
+
on('session.usage', () => ({ value: { startedAt: NOW, context: { percent, tokens: 40000, window: 200000 }, rateLimits: options.limits ?? [] } }))
|
|
121
|
+
on('ui.status', () => ({ value: undefined }))
|
|
122
|
+
on('ui.toast', ($, e) => { toasts.push(e.text); return { value: undefined } })
|
|
123
|
+
on('ui.log', ($, e) => { logs.push(e.text); return { value: undefined } })
|
|
124
|
+
on('ui.panes', () => ({ value: [...panes].map(id => ({ id, title: id, isShown: true, isFocused: false, isPlaced: true })) }))
|
|
125
|
+
on('ui.open', ($, e) => { panes.add(e.id); return { value: { isPlaced: true } } })
|
|
126
|
+
on('ui.close', ($, e) => { panes.delete(e.id); return { value: undefined } })
|
|
127
|
+
on('ui.render', () => ({ type: 'Text', props: {}, children: ['host drawing'] }))
|
|
128
|
+
// What the model reads beside each prompt, as the hooks attached it.
|
|
129
|
+
const contexts: (readonly string[])[] = []
|
|
130
|
+
on('prompt.submit', ($, e) => { submitted.push(e.text); contexts.push(e.context ?? []); return { text: e.text } })
|
|
131
|
+
on('turn.complete', () => ({ text: '' }))
|
|
132
|
+
on('prompt.read', () => ({ value: { text: '', cursor: 0 } }))
|
|
133
|
+
on('prompt.fill', ($, e) => { filled.push(e.text); return { isFilled: true } })
|
|
134
|
+
return { files, failWorkerLog: (value: 'read' | 'write' | null) => { logFailure = value }, registered, clock, saved, storeWrites, storeReads, settingsRequests, router: () => router, remote: (value: ModSettingsSnapshot) => { router = value }, offline: (value: boolean) => { offline = value }, conflict: (value: Settings) => { conflict = value }, alwaysConflict: (value: boolean) => { alwaysConflict = value }, toasts, logs, panes, submitted, contexts, filled, processes, language: () => resolvedLanguage, setLanguage: (value: unknown) => { hostLanguage = value }, setModel: (id: string) => { model = id }, setPercent: (value: number) => { percent = value } }
|
|
135
|
+
}
|
|
136
|
+
function http(on: On, exhausted = false, config: unknown = CONFIG, zen?: (input: { url: string; init?: { body?: string; headers?: Readonly<Record<string, string>> } }) => void) {
|
|
137
|
+
const urls: string[] = []
|
|
138
|
+
on('http.fetch', ($, e, next) => {
|
|
139
|
+
if (e.url.endsWith('/api/mod-settings')) return next(e)
|
|
140
|
+
urls.push(e.url)
|
|
141
|
+
if (e.url === 'https://opencode.ai/zen/v1/systemone' && zen) {
|
|
142
|
+
zen(e)
|
|
143
|
+
return { value: { ok: true, status: 200, headers: {}, text: JSON.stringify({ model: 'jev-1.13', answers: { effort: { choice: 'high', confidence: 0.95 } }, usage: { input_tokens: 50, output_tokens: 10 } }) } }
|
|
144
|
+
}
|
|
145
|
+
const body = e.url.endsWith('/api/status') ? { chatgpt: { accounts: { chatgpt: [{ state: 'ready', quota: { rate_limits: { primary: { used_percent: exhausted ? 100 : 20 } } } }] } } } : e.url.endsWith('/api/config') ? config : e.url.endsWith('/api/mod-options') ? { ...MOD_OPTIONS, providers: MOD_OPTIONS.providers.map(p => p.name === 'chatgpt' ? { ...p, usable: !exhausted } : p) } : { requests: [] }
|
|
146
|
+
return { value: { ok: true, status: 200, headers: {}, text: JSON.stringify(body) } }
|
|
147
|
+
})
|
|
148
|
+
return urls
|
|
149
|
+
}
|
|
150
|
+
function judgeStub(on: On, text = '{"domain":"general","level":"light","effort":"low","sure":0.9,"why":"small rename"}') {
|
|
151
|
+
const calls: ModelCompleteRequest[] = []
|
|
152
|
+
on('model.complete', ($, e) => { calls.push(e); return { value: { isAnswered: true, text, usage: USAGE } } })
|
|
153
|
+
return calls
|
|
154
|
+
}
|
|
155
|
+
function spawnStub(on: On) {
|
|
156
|
+
const sent: AgentSpawnInput[] = []
|
|
157
|
+
on('agent.spawn', ($, e) => { sent.push(e); return { model: e.model ?? 'sonnet', agentId: 'worker-1' } })
|
|
158
|
+
return sent
|
|
159
|
+
}
|
|
160
|
+
function stepStub(on: On, usage: ModelUsage | null = USAGE) {
|
|
161
|
+
const sent: TurnStepInput[] = []
|
|
162
|
+
on('turn.step', async function* ($, e) {
|
|
163
|
+
sent.push(e)
|
|
164
|
+
yield { kind: 'text', index: 0, text: 'ok' }
|
|
165
|
+
return { turnId: e.turnId, index: e.index, answer: 'ok', toolUses: [], stopReason: 'end_turn', usage: usage ? { ...usage, model: e.model } : null }
|
|
166
|
+
})
|
|
167
|
+
return sent
|
|
168
|
+
}
|
|
169
|
+
async function command($: Engine, args: string) {
|
|
170
|
+
return $.command.run({ command: 'ripple', args, origin: { kind: 'composer' }, presentation: { isFullscreen: true, columns: 140 } })
|
|
171
|
+
}
|
|
172
|
+
async function start($: Engine) { await $.session.start({ cwd: '/work', surface: 'terminal', isInteractive: true }) }
|
|
173
|
+
async function step($: Engine, model = 'claude-opus-5-5', effort: TurnStepInput['effort'] = 'medium', agentId?: string) {
|
|
174
|
+
const stream = $.turn.step({ turnId: 'turn-1', index: 0, messageCount: 2, model, effort, ...(agentId ? { agentId } : {}) })
|
|
175
|
+
let result = await stream.next()
|
|
176
|
+
while (result.done !== true) result = await stream.next()
|
|
177
|
+
return result.value
|
|
178
|
+
}
|
|
179
|
+
async function submit($: Engine, text = 'Implement the bounded change and verify the tests.', turnId?: string) {
|
|
180
|
+
return $.prompt.submit({ text, origin: { kind: 'composer' }, wait: false, ...(turnId ? { turnId } : {}) })
|
|
181
|
+
}
|
|
182
|
+
async function complete($: Engine, agentId?: string, answer = 'Finished.') {
|
|
183
|
+
await $.turn.complete({ turnId: 'turn-1', answer, reason: 'answer', durationMs: 1000, isAborted: false, ...(agentId ? { agentId } : {}) })
|
|
184
|
+
}
|
|
185
|
+
async function band($: Engine, surface: 'terminal' | 'desktop' = 'terminal', hasSurvey = false) {
|
|
186
|
+
return $.ui.mount({ plugin: 'ripple', component: 'AbovePrompt', requestId: 'band', surface,
|
|
187
|
+
viewport: { columns: 140, rows: 40 }, props: { hasSurvey, isWorking: false, bodyColumns: 130, maxRows: 20, scroll: { offset: 0, bodyRows: 20 }, view: {} } })
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
test('router settings override legacy store and revision changes redraw even with the band hidden', async ($, on) => {
|
|
191
|
+
const b = boundaries(on, { language: 'pt' })
|
|
192
|
+
b.saved.set('settings', normalizeSettings({ language: 'ko' }))
|
|
193
|
+
http(on)
|
|
194
|
+
await start($)
|
|
195
|
+
expectLanguage(b, 'pt')
|
|
196
|
+
expect(b.storeReads).not.toContain('settings')
|
|
197
|
+
await command($, 'bar')
|
|
198
|
+
b.remote({ settings: normalizeSettings({ setupDone: true, language: 'ja', effortAuto: false }), revision: 2, stored: true })
|
|
199
|
+
await b.clock.advance(3000)
|
|
200
|
+
expectLanguage(b, 'ja')
|
|
201
|
+
await command($, 'bar')
|
|
202
|
+
const ui = await band($)
|
|
203
|
+
expect((await ui.find({ key: 'auto' }))?.props.label).toContain('○')
|
|
204
|
+
b.remote({ settings: normalizeSettings({ setupDone: true, language: 'ko' }), revision: 2, stored: true })
|
|
205
|
+
await b.clock.advance(3000)
|
|
206
|
+
expectLanguage(b, 'ja') // An unchanged revision does not replace the atom.
|
|
207
|
+
expect(b.settingsRequests.filter(r => r.method === 'PUT').length).toBe(0)
|
|
208
|
+
await ui.unmount()
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('an unrelated user change uses the fresh router ladder and saved modes, not a local whole snapshot', async ($, on) => {
|
|
212
|
+
const b = boundaries(on)
|
|
213
|
+
http(on)
|
|
214
|
+
await start($)
|
|
215
|
+
await command($, 'settings')
|
|
216
|
+
const ui = await band($)
|
|
217
|
+
const steps = [{ target: { agent: 'gpt-6-astra' }, effort: 'high' as const }, { target: { agent: 'gpt-6-1-sol' }, effort: 'medium' as const }]
|
|
218
|
+
const fresh = normalizeSettings({ setupDone: true, rules: { general: { mode: 'ladder', rungs: { hard: steps } } }, modes: [{ id: 'saved', name: 'Saved', rungs: { plan: steps }, until: 'none' }], activeMode: 'saved' })
|
|
219
|
+
b.remote({ settings: fresh, revision: 2, stored: true })
|
|
220
|
+
await ui.press({ key: 'bias-1' }) // No poll has installed the newer router snapshot yet.
|
|
221
|
+
expect(b.router().settings.rules).toEqual(fresh.rules)
|
|
222
|
+
expect(b.router().settings.modes).toEqual(fresh.modes)
|
|
223
|
+
expect(b.router().settings.activeMode).toBe('saved')
|
|
224
|
+
const writes = b.settingsRequests.filter(r => r.method === 'PUT')
|
|
225
|
+
expect(writes.length).toBe(1)
|
|
226
|
+
expect(writes[0]?.revision).toBe(2)
|
|
227
|
+
expect(writes[0]?.schemaVersion).toBe(MOD_SETTINGS_SCHEMA_VERSION)
|
|
228
|
+
await ui.unmount()
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('reload discards old-format pending overwrites and migration even at the same stored revision', async ($, on) => {
|
|
232
|
+
const b = boundaries(on)
|
|
233
|
+
http(on)
|
|
234
|
+
const original = b.router().settings
|
|
235
|
+
let oldState = true
|
|
236
|
+
let installed: unknown
|
|
237
|
+
on('state.get', { plugin: 'ripple', key: 'settingsSync' }, ($, e, next) => oldState ? { value: { value: { revision: 1, pending: [{ path: ['rules'], value: {} }, { path: ['modes'], value: [] }], migrationChecked: true, migration: normalizeSettings(null) }, version: 0 } } : next(e))
|
|
238
|
+
on('state.set', { plugin: 'ripple', key: 'settingsSync' }, async ($, e, next) => {
|
|
239
|
+
const result = await next(e)
|
|
240
|
+
oldState = false
|
|
241
|
+
installed = e.value
|
|
242
|
+
return result
|
|
243
|
+
})
|
|
244
|
+
await start($)
|
|
245
|
+
expect(b.settingsRequests.filter(r => r.method === 'PUT').length).toBe(0)
|
|
246
|
+
expect(b.router().settings).toEqual(original)
|
|
247
|
+
expect(installed).toMatchObject({ schemaVersion: MOD_SETTINGS_SCHEMA_VERSION, pending: [], migration: null })
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
test('saving the already-selected setting sends no normalization-only PUT', async ($, on) => {
|
|
251
|
+
const b = boundaries(on)
|
|
252
|
+
http(on)
|
|
253
|
+
await start($)
|
|
254
|
+
await command($, 'settings')
|
|
255
|
+
const ui = await band($)
|
|
256
|
+
await ui.press({ key: 'bias-0' })
|
|
257
|
+
expect(b.settingsRequests.filter(r => r.method === 'PUT').length).toBe(0)
|
|
258
|
+
expect(b.router().revision).toBe(1)
|
|
259
|
+
await ui.unmount()
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
test('empty router uses defaults without writing when there is no legacy store', async ($, on) => {
|
|
263
|
+
const b = boundaries(on)
|
|
264
|
+
b.remote({ settings: normalizeSettings(null), revision: 0, stored: false })
|
|
265
|
+
http(on)
|
|
266
|
+
await start($)
|
|
267
|
+
const ui = await band($)
|
|
268
|
+
expect(await ui.find({ type: 'Text', text: 'Welcome to ClaudeRipple' })).toBeDefined()
|
|
269
|
+
expect(b.settingsRequests.filter(r => r.method === 'PUT').length).toBe(0)
|
|
270
|
+
await ui.unmount()
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
test('legacy settings and setup completion migrate once; unrelated store keys survive', async ($, on) => {
|
|
274
|
+
const b = boundaries(on)
|
|
275
|
+
b.remote({ settings: normalizeSettings(null), revision: 0, stored: false })
|
|
276
|
+
b.saved.set('settings', { language: 'pt', keepWarm: true, tiers: { light: { agent: 'old-worker' } } })
|
|
277
|
+
b.saved.set('setupDone', true)
|
|
278
|
+
b.saved.set('handoffChoice', { kind: 'full', after: 'copy' })
|
|
279
|
+
http(on)
|
|
280
|
+
await start($)
|
|
281
|
+
expect(b.router().settings).toMatchObject({ language: 'pt', keepWarm: true, setupDone: true, rules: { general: { mode: 'ladder', rungs: { light: [{ target: { agent: 'old-worker' } }] } } } })
|
|
282
|
+
expect(b.settingsRequests.filter(r => r.method === 'PUT').length).toBe(1)
|
|
283
|
+
expect(b.settingsRequests.find(r => r.method === 'PUT')?.revision).toBe(0)
|
|
284
|
+
b.saved.set('settings', { language: 'ko' })
|
|
285
|
+
await b.clock.advance(3000)
|
|
286
|
+
await $.classic.SessionStart({ source: 'clear' })
|
|
287
|
+
expectLanguage(b, 'pt')
|
|
288
|
+
expect(b.storeReads.filter(key => key === 'settings').length).toBe(1)
|
|
289
|
+
expect(b.storeWrites).not.toContain('settings')
|
|
290
|
+
expect(b.saved.get('handoffChoice')).toEqual({ kind: 'full', after: 'copy' })
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
test('a quick change retries a stale revision once and preserves remote sibling fields', async ($, on) => {
|
|
294
|
+
const b = boundaries(on)
|
|
295
|
+
http(on)
|
|
296
|
+
await start($)
|
|
297
|
+
await command($, 'settings')
|
|
298
|
+
const ui = await band($)
|
|
299
|
+
b.conflict(normalizeSettings({ setupDone: true, ceiling: 'high', language: 'pt', allow: ['remote'] }))
|
|
300
|
+
await ui.press({ key: 'bias-1' })
|
|
301
|
+
const writes = b.settingsRequests.filter(r => r.method === 'PUT')
|
|
302
|
+
expect(writes.map(r => r.revision)).toEqual([1, 2])
|
|
303
|
+
expect(b.router().settings).toMatchObject({ bias: 1, ceiling: 'high', language: 'pt', allow: ['remote'] })
|
|
304
|
+
expectLanguage(b, 'pt')
|
|
305
|
+
expect(b.storeWrites).not.toContain('settings')
|
|
306
|
+
await ui.unmount()
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
test('two conflicts stop the save retry and retain the edit for the next poll', async ($, on) => {
|
|
310
|
+
const b = boundaries(on)
|
|
311
|
+
http(on)
|
|
312
|
+
await start($)
|
|
313
|
+
await command($, 'settings')
|
|
314
|
+
const ui = await band($)
|
|
315
|
+
b.alwaysConflict(true)
|
|
316
|
+
await ui.press({ key: 'bias-1' })
|
|
317
|
+
expect(b.settingsRequests.filter(r => r.method === 'PUT').length).toBe(2)
|
|
318
|
+
expect(b.router().settings.bias).toBe(0)
|
|
319
|
+
// Opening the panel syncs first, which tries the kept edit again (two more conflicts).
|
|
320
|
+
await command($, 'settings')
|
|
321
|
+
expect((await ui.find({ key: 'bias-1' }))?.props.label).toBe('● +1')
|
|
322
|
+
expect(b.settingsRequests.filter(r => r.method === 'PUT').length).toBe(4)
|
|
323
|
+
b.alwaysConflict(false)
|
|
324
|
+
await b.clock.advance(3000)
|
|
325
|
+
expect(b.router().settings.bias).toBe(1)
|
|
326
|
+
expect(b.settingsRequests.filter(r => r.method === 'PUT').length).toBe(5)
|
|
327
|
+
await ui.unmount()
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
test('migration loses a first-write race without overwriting existing server settings', async ($, on) => {
|
|
331
|
+
const b = boundaries(on)
|
|
332
|
+
b.remote({ settings: normalizeSettings(null), revision: 0, stored: false })
|
|
333
|
+
b.saved.set('settings', { language: 'pt' })
|
|
334
|
+
b.conflict(normalizeSettings({ setupDone: true, language: 'ja', keepWarm: true }))
|
|
335
|
+
http(on)
|
|
336
|
+
await start($)
|
|
337
|
+
expectLanguage(b, 'ja')
|
|
338
|
+
expect(b.settingsRequests.filter(r => r.method === 'PUT').length).toBe(1)
|
|
339
|
+
expect(b.router().settings).toMatchObject({ language: 'ja', keepWarm: true })
|
|
340
|
+
await b.clock.advance(3000)
|
|
341
|
+
expect(b.storeReads.filter(key => key === 'settings').length).toBe(1)
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
test('offline startup recovers and migrates legacy settings only after reaching an empty router', async ($, on) => {
|
|
345
|
+
const b = boundaries(on)
|
|
346
|
+
b.remote({ settings: normalizeSettings(null), revision: 0, stored: false })
|
|
347
|
+
b.saved.set('settings', { language: 'pt', setupDone: true })
|
|
348
|
+
b.offline(true)
|
|
349
|
+
http(on)
|
|
350
|
+
await start($)
|
|
351
|
+
expect(b.storeReads).not.toContain('settings')
|
|
352
|
+
b.offline(false)
|
|
353
|
+
await b.clock.advance(3000)
|
|
354
|
+
expectLanguage(b, 'pt')
|
|
355
|
+
expect(b.router().stored).toBe(true)
|
|
356
|
+
expect(b.settingsRequests.filter(r => r.method === 'PUT').length).toBe(1)
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
test('an open quick panel follows newer settings and saves only the next change', async ($, on) => {
|
|
360
|
+
const b = boundaries(on)
|
|
361
|
+
http(on)
|
|
362
|
+
await start($)
|
|
363
|
+
await command($, 'settings')
|
|
364
|
+
const ui = await band($)
|
|
365
|
+
b.remote({ settings: normalizeSettings({ setupDone: true, bias: -1, ceiling: 'high', keepWarmHours: 8 }), revision: 2, stored: true })
|
|
366
|
+
await b.clock.advance(3000)
|
|
367
|
+
expect((await ui.find({ key: 'bias--1' }))?.props.label).toBe('● −1')
|
|
368
|
+
await ui.press({ key: 'bias-1' })
|
|
369
|
+
expect(b.router().settings).toMatchObject({ bias: 1, ceiling: 'high', keepWarmHours: 8 })
|
|
370
|
+
expect(b.settingsRequests.filter(r => r.method === 'PUT').map(r => r.revision)).toEqual([2])
|
|
371
|
+
await ui.unmount()
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
test('offline quick changes stay local, show router notice and retry on next poll', async ($, on) => {
|
|
375
|
+
const b = boundaries(on)
|
|
376
|
+
http(on)
|
|
377
|
+
await start($)
|
|
378
|
+
await command($, 'settings')
|
|
379
|
+
const ui = await band($)
|
|
380
|
+
b.offline(true)
|
|
381
|
+
await ui.press({ key: 'bias-1' })
|
|
382
|
+
expect(b.router().settings.bias).toBe(0)
|
|
383
|
+
await command($, 'settings')
|
|
384
|
+
expect((await ui.find({ key: 'bias-1' }))?.props.label).toBe('● +1')
|
|
385
|
+
expect(b.toasts.some(text => text.includes('router not answering'))).toBe(true)
|
|
386
|
+
expect(b.toasts).not.toContain('Settings saved')
|
|
387
|
+
expect(b.storeWrites).not.toContain('settings')
|
|
388
|
+
b.remote({ settings: normalizeSettings({ setupDone: true, ceiling: 'high' }), revision: 2, stored: true })
|
|
389
|
+
b.offline(false)
|
|
390
|
+
await b.clock.advance(3000)
|
|
391
|
+
expect(b.router().settings).toMatchObject({ bias: 1, ceiling: 'high' })
|
|
392
|
+
expect(b.settingsRequests.filter(r => r.method === 'PUT').map(r => r.revision)).toEqual([2])
|
|
393
|
+
await ui.unmount()
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
function expectLanguage(boundary: ReturnType<typeof boundaries>, language: Lang): void {
|
|
397
|
+
expect(boundary.language()).toBe(language)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
test('explicit mod language wins over host settings and locale', async ($, on) => {
|
|
401
|
+
const b = boundaries(on, { language: 'pt' }, { language: 'Korean', env: { LANG: 'ja_JP', LC_ALL: 'es_ES' } })
|
|
402
|
+
http(on)
|
|
403
|
+
await start($)
|
|
404
|
+
expectLanguage(b, 'pt')
|
|
405
|
+
expect(b.processes).toEqual([])
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
test('auto prefers Claude Code language over LANG and LC_ALL', async ($, on) => {
|
|
409
|
+
const b = boundaries(on, {}, { language: '한국어', env: { LANG: 'ja_JP', LC_ALL: 'es_ES' } })
|
|
410
|
+
http(on)
|
|
411
|
+
await start($)
|
|
412
|
+
expectLanguage(b, 'ko')
|
|
413
|
+
expect(b.processes).toEqual([])
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
test('off macOS, auto checks LANG then LC_ALL when the host setting is unsupported', async ($, on) => {
|
|
417
|
+
const b = boundaries(on, {}, { language: 12, env: { LANG: 'zh-Hans', LC_ALL: 'pt_BR' } })
|
|
418
|
+
http(on)
|
|
419
|
+
await start($)
|
|
420
|
+
expectLanguage(b, 'zh')
|
|
421
|
+
expect(b.processes).toEqual([{ argv: ['uname', '-s'], timeoutMs: 2000 }])
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
// The desktop app can start a session with LANG=en_US on a Mac set to Korean (2026-10-10).
|
|
425
|
+
test('on macOS the system language wins over LANG', async ($, on) => {
|
|
426
|
+
const b = boundaries(on, {}, { env: { LANG: 'en_US.UTF-8' }, platform: 'Darwin', appleLocale: 'ko_KR\n' })
|
|
427
|
+
http(on)
|
|
428
|
+
await start($)
|
|
429
|
+
expectLanguage(b, 'ko')
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
test('auto accepts LC_ALL after an unsupported LANG', async ($, on) => {
|
|
433
|
+
const b = boundaries(on, {}, { env: { LANG: 'C.UTF-8', LC_ALL: 'es_ES' } })
|
|
434
|
+
http(on)
|
|
435
|
+
await start($)
|
|
436
|
+
expectLanguage(b, 'es')
|
|
437
|
+
expect(b.processes).toEqual([{ argv: ['uname', '-s'], timeoutMs: 2000 }])
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
test('auto reads AppleLocale only on macOS with a two-second timeout', async ($, on) => {
|
|
441
|
+
const b = boundaries(on, {}, { env: { LANG: 'C', LC_ALL: '' }, platform: 'Darwin', appleLocale: 'ja_JP\n' })
|
|
442
|
+
http(on)
|
|
443
|
+
await start($)
|
|
444
|
+
expectLanguage(b, 'ja')
|
|
445
|
+
expect(b.processes).toEqual([{ argv: ['uname', '-s'], timeoutMs: 2000 }, { argv: ['defaults', 'read', '-g', 'AppleLocale'], timeoutMs: 2000 }])
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
test('auto defaults to English off macOS', async ($, on) => {
|
|
449
|
+
const b = boundaries(on, {}, { env: { LANG: 'C', LC_ALL: '' } })
|
|
450
|
+
http(on)
|
|
451
|
+
await start($)
|
|
452
|
+
expectLanguage(b, 'en')
|
|
453
|
+
expect(b.processes.length).toBe(1)
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
test('a failed macOS locale lookup logs the failure and falls back without preventing startup', async ($, on) => {
|
|
457
|
+
const b = boundaries(on, {}, { env: { LANG: 'C', LC_ALL: '' }, platform: 'Darwin', appleFail: true })
|
|
458
|
+
http(on)
|
|
459
|
+
await start($)
|
|
460
|
+
expectLanguage(b, 'en')
|
|
461
|
+
expect(b.logs.some(log => log.includes('locale timed out'))).toBe(true)
|
|
462
|
+
expect((await command($, 'stats')).text).toContain('Auto:')
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
test('dashboard language changes re-resolve auto from current host settings', async ($, on) => {
|
|
466
|
+
const b = boundaries(on, { language: 'pt' }, { language: 'Korean' })
|
|
467
|
+
http(on)
|
|
468
|
+
await start($)
|
|
469
|
+
b.remote({ settings: normalizeSettings({ setupDone: true, language: 'auto' }), revision: 2, stored: true })
|
|
470
|
+
await b.clock.advance(3000)
|
|
471
|
+
expectLanguage(b, 'ko')
|
|
472
|
+
b.setLanguage('Japanese')
|
|
473
|
+
await command($, 'settings')
|
|
474
|
+
const ui = await band($)
|
|
475
|
+
await ui.press({ key: 'bias-1' })
|
|
476
|
+
expectLanguage(b, 'ja')
|
|
477
|
+
expect(b.router().settings.language).toBe('auto')
|
|
478
|
+
await ui.unmount()
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
test('ripple:auto is judged and rewritten to the light rung\'s agent at the rung\'s effort, and the pick is logged', async ($, on) => {
|
|
482
|
+
const b = boundaries(on)
|
|
483
|
+
const urls = http(on)
|
|
484
|
+
const judges = judgeStub(on)
|
|
485
|
+
const sent = spawnStub(on)
|
|
486
|
+
await start($)
|
|
487
|
+
const result = await $.agent.spawn(SPAWN)
|
|
488
|
+
expect(result.agentId).toBe('worker-1')
|
|
489
|
+
// The default ladder runs light work on DeepSeek at max, whatever effort the judge named.
|
|
490
|
+
expect(sent[0]).toMatchObject({ subagentType: 'deepseek-v4-1-flash', model: 'deepseek-v4.1-flash@max' })
|
|
491
|
+
expect(judges[0]?.model).toBe('claude-haiku-5-5')
|
|
492
|
+
expect(urls).toContain('http://127.0.0.1:9992/api/status')
|
|
493
|
+
// The only debug line is the pick itself: no error was reported on the way.
|
|
494
|
+
expect(b.logs.length).toBe(1)
|
|
495
|
+
expect(b.logs[0]!.startsWith('ClaudeRipple worker pick: ')).toBe(true)
|
|
496
|
+
expect(JSON.parse(b.logs[0]!.slice('ClaudeRipple worker pick: '.length))).toEqual({
|
|
497
|
+
task: 'Rename variable', domain: 'general', level: 'light', kind: 'do', sure: 0.9, rung: 'light', position: 1, skipped: [],
|
|
498
|
+
target: { agent: 'deepseek-v4-1-flash' }, model: 'deepseek-v4.1-flash@max', effort: 'max', by: 'haiku',
|
|
499
|
+
})
|
|
500
|
+
const persisted = b.files.get('/home/test/.clauderipple/logs/worker-picks.jsonl')!
|
|
501
|
+
expect(persisted.endsWith('\n')).toBe(true)
|
|
502
|
+
expect(persisted.trim().split('\n').length).toBe(1)
|
|
503
|
+
expect(JSON.parse(persisted)).toEqual({
|
|
504
|
+
...JSON.parse(b.logs[0]!.slice('ClaudeRipple worker pick: '.length)),
|
|
505
|
+
at: new Date(NOW).toISOString(), by: 'judge', judge: 'haiku', ms: 0,
|
|
506
|
+
verdict: { domain: 'general', level: 'light', kind: 'do', effort: 'low', sure: 0.9, why: 'small rename' },
|
|
507
|
+
})
|
|
508
|
+
const stats = await $.command.run({ command: 'ripple', origin: { kind: 'composer' }, presentation: { isFullscreen: true, columns: 140 }, args: 'stats' })
|
|
509
|
+
expect(stats.text).toContain('light 1')
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
test('worker pick persistence keeps the last 300 lines and concurrent marker picks, never the prompt', async ($, on) => {
|
|
513
|
+
const b = boundaries(on, {}, { env: { CLAUDERIPPLE_HOME: '/custom/ripple' } })
|
|
514
|
+
http(on)
|
|
515
|
+
const judges = judgeStub(on)
|
|
516
|
+
spawnStub(on)
|
|
517
|
+
const path = '/custom/ripple/logs/worker-picks.jsonl'
|
|
518
|
+
b.files.set(path, Array.from({ length: 305 }, (_, n) => JSON.stringify({ old: n })).join('\n') + '\n')
|
|
519
|
+
await start($)
|
|
520
|
+
await Promise.all(['first', 'second'].map(label => $.agent.spawn({ ...SPAWN, description: label + 'x'.repeat(220), prompt: '[[ripple: gpt-6-1-sol@xhigh]]\nPRIVATE_TASK_PROMPT' })))
|
|
521
|
+
const text = b.files.get(path)!
|
|
522
|
+
const rows = text.trim().split('\n').map(line => JSON.parse(line))
|
|
523
|
+
expect(rows.length).toBe(300)
|
|
524
|
+
expect(rows[0]).toEqual({ old: 7 })
|
|
525
|
+
expect(rows.slice(-2).map(row => row.task).sort()).toEqual(['first' + 'x'.repeat(195), 'second' + 'x'.repeat(194)])
|
|
526
|
+
for (const row of rows.slice(-2)) expect(row).toMatchObject({ by: 'marker', verdict: null, judge: null, ms: 0, model: 'gpt-6.1-sol@xhigh', effort: 'xhigh' })
|
|
527
|
+
expect(text).not.toContain('PRIVATE_TASK_PROMPT')
|
|
528
|
+
expect(b.logs.join('\n')).not.toContain('PRIVATE_TASK_PROMPT')
|
|
529
|
+
expect(judges.length).toBe(0)
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
for (const failure of ['read', 'write'] as const) test(`worker log ${failure} failure reports without changing the spawn and later recovers`, async ($, on) => {
|
|
533
|
+
const b = boundaries(on)
|
|
534
|
+
http(on)
|
|
535
|
+
judgeStub(on)
|
|
536
|
+
const sent = spawnStub(on)
|
|
537
|
+
const path = '/home/test/.clauderipple/logs/worker-picks.jsonl'
|
|
538
|
+
const previous = JSON.stringify({ previous: true }) + '\n'
|
|
539
|
+
b.files.set(path, previous)
|
|
540
|
+
b.failWorkerLog(failure)
|
|
541
|
+
await start($)
|
|
542
|
+
expect((await $.agent.spawn(SPAWN)).agentId).toBe('worker-1')
|
|
543
|
+
expect(sent[0]).toMatchObject({ subagentType: 'deepseek-v4-1-flash', model: 'deepseek-v4.1-flash@max' })
|
|
544
|
+
expect(b.logs.some(log => log.includes('worker pick log') && log.includes(`worker log ${failure} denied`))).toBe(true)
|
|
545
|
+
expect(b.files.get(path)).toBe(previous)
|
|
546
|
+
b.failWorkerLog(null)
|
|
547
|
+
await $.agent.spawn(SPAWN)
|
|
548
|
+
expect(b.files.get(path)!.trim().split('\n').length).toBe(2)
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
for (const error of ['api-error: overloaded (503)', 'aborted'] as const) test(`failed worker judge persists defaults with ${error}`, async ($, on) => {
|
|
552
|
+
const b = boundaries(on)
|
|
553
|
+
http(on)
|
|
554
|
+
on('model.complete', async ($, e) => {
|
|
555
|
+
expect(e.timeoutMs).toBe(6000)
|
|
556
|
+
if (error === 'aborted') await b.clock.advance(6000)
|
|
557
|
+
return { value: error === 'aborted' ? { isAnswered: false, reason: 'aborted', usage: USAGE } : { isAnswered: false, reason: 'api-error', status: 503, error: 'overloaded', usage: USAGE } }
|
|
558
|
+
})
|
|
559
|
+
const sent = spawnStub(on)
|
|
560
|
+
await start($)
|
|
561
|
+
expect((await $.agent.spawn(SPAWN)).agentId).toBe('worker-1')
|
|
562
|
+
const row = JSON.parse(b.files.get('/home/test/.clauderipple/logs/worker-picks.jsonl')!)
|
|
563
|
+
expect(row).toMatchObject({ by: 'default', verdict: null, judge: null, error, ms: error === 'aborted' ? 12000 : 0, rung: 'standard', model: 'gpt-6.1-sol@medium', effort: 'medium', position: 1, skipped: [] })
|
|
564
|
+
expect(sent[0]).toMatchObject({ subagentType: 'gpt-6-1-sol', model: 'gpt-6.1-sol@medium' })
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
// The main chat only delegates; the judge picks among all four workers (2026-10-12 revision).
|
|
568
|
+
for (const prefix of ['ripple:', '']) for (const [level, kind, rung, agent, model, effort] of [
|
|
569
|
+
['light', 'do', 'light', 'deepseek-v4-1-flash', 'deepseek-v4.1-flash@max', 'max'],
|
|
570
|
+
['standard', 'do', 'standard', 'gpt-6-1-sol', 'gpt-6.1-sol@medium', 'medium'],
|
|
571
|
+
['deep', 'do', 'hard', 'gpt-6-astra', 'gpt-6-astra@high', 'high'],
|
|
572
|
+
['deep', 'plan', 'plan', 'general-purpose', 'opus', 'high'],
|
|
573
|
+
] as const) test(`${prefix}auto: a ${level} ${kind} verdict runs the ${rung} rung`, async ($, on) => {
|
|
574
|
+
const b = boundaries(on)
|
|
575
|
+
http(on)
|
|
576
|
+
const judges = judgeStub(on, JSON.stringify({ domain: 'general', level, kind, effort: 'low', sure: 0.9, why: 'judged' }))
|
|
577
|
+
const sent = spawnStub(on)
|
|
578
|
+
const steps = stepStub(on)
|
|
579
|
+
await start($)
|
|
580
|
+
await $.agent.spawn({ ...SPAWN, subagentType: prefix + 'auto' })
|
|
581
|
+
expect(judges.length).toBe(1)
|
|
582
|
+
expect(sent[0]).toMatchObject({ subagentType: agent, model })
|
|
583
|
+
expect(JSON.parse(b.logs.find(log => log.startsWith('ClaudeRipple worker pick: '))!.slice('ClaudeRipple worker pick: '.length))).toMatchObject({ level, kind, rung, by: 'haiku', effort })
|
|
584
|
+
// Named workers carry effort in their model suffix; Claude's own agent gets it on turn.step.
|
|
585
|
+
if (agent === 'general-purpose') {
|
|
586
|
+
await step($, 'claude-opus-5-5', 'low', 'worker-1')
|
|
587
|
+
expect(steps[0]?.effort).toBe(effort)
|
|
588
|
+
}
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
test('a judged spawn uses the default general ladder when general is inherit', async ($, on) => {
|
|
592
|
+
boundaries(on, { rules: normalizeSettings({ rules: { general: { mode: 'inherit' } } }).rules })
|
|
593
|
+
http(on)
|
|
594
|
+
const judges = judgeStub(on, '{"domain":"backend","level":"standard","effort":"low","sure":0.9,"why":"implementation"}')
|
|
595
|
+
const sent = spawnStub(on)
|
|
596
|
+
await start($)
|
|
597
|
+
await $.agent.spawn(SPAWN)
|
|
598
|
+
expect(sent[0]).toMatchObject({ subagentType: 'gpt-6-1-sol', model: 'gpt-6.1-sol@medium' })
|
|
599
|
+
expect(judges.length).toBe(1)
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
test('a judged spawn uses the configured general ladder, then one rung up on a missing worker', async ($, on) => {
|
|
603
|
+
const rules = normalizeSettings({ rules: { general: { mode: 'ladder', rungs: {
|
|
604
|
+
light: { target: { agent: 'missing-worker' }, effort: 'low' },
|
|
605
|
+
standard: { target: { agent: 'gpt-6-astra' }, effort: 'xhigh' },
|
|
606
|
+
} } } }).rules
|
|
607
|
+
const b = boundaries(on, { rules })
|
|
608
|
+
http(on)
|
|
609
|
+
judgeStub(on)
|
|
610
|
+
const sent = spawnStub(on)
|
|
611
|
+
await start($)
|
|
612
|
+
await $.agent.spawn(SPAWN)
|
|
613
|
+
expect(sent[0]).toMatchObject({ subagentType: 'gpt-6-astra', model: 'gpt-6-astra@xhigh' })
|
|
614
|
+
expect(b.toasts.some(text => text.includes('missing-worker'))).toBe(true)
|
|
615
|
+
expect(JSON.parse(b.logs[0]!.slice('ClaudeRipple worker pick: '.length))).toMatchObject({ level: 'light', rung: 'standard', by: 'haiku' })
|
|
616
|
+
const persisted = JSON.parse(b.files.get('/home/test/.clauderipple/logs/worker-picks.jsonl')!)
|
|
617
|
+
expect(persisted).toMatchObject({ verdict: { level: 'light', effort: 'low' }, rung: 'standard', model: 'gpt-6-astra@xhigh', effort: 'xhigh', by: 'judge' })
|
|
618
|
+
expect(persisted.skipped.length).toBeGreaterThan(0)
|
|
619
|
+
expect(persisted.fallbackReason).toContain('missing-worker')
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
test('exhaustion falls to the session after only one rung up; a plan verdict never moves down', async ($, on) => {
|
|
623
|
+
const rules = normalizeSettings({ rules: { general: { mode: 'ladder', rungs: {
|
|
624
|
+
standard: { target: { agent: 'gpt-6-1-sol' }, effort: 'medium' },
|
|
625
|
+
hard: { target: { agent: 'gpt-6-astra' }, effort: 'high' },
|
|
626
|
+
plan: { target: { agent: 'missing-max' }, effort: 'xhigh' },
|
|
627
|
+
} } } }).rules
|
|
628
|
+
const b = boundaries(on, { rules })
|
|
629
|
+
http(on, true)
|
|
630
|
+
const verdicts = [
|
|
631
|
+
'{"domain":"backend","level":"standard","kind":"do","effort":"low","sure":0.9,"why":"implementation"}',
|
|
632
|
+
'{"domain":"general","level":"deep","kind":"plan","effort":"high","sure":0.9,"why":"architecture"}',
|
|
633
|
+
]
|
|
634
|
+
on('model.complete', () => ({ value: { isAnswered: true, text: verdicts.shift() ?? verdicts[0] ?? '', usage: USAGE } }))
|
|
635
|
+
const sent = spawnStub(on)
|
|
636
|
+
await start($)
|
|
637
|
+
for (let i = 0; i < 2; i++) await $.agent.spawn(SPAWN)
|
|
638
|
+
expect(sent.map(e => [e.subagentType, e.model])).toEqual([['general-purpose', undefined], ['general-purpose', undefined]])
|
|
639
|
+
expect(b.toasts.some(text => text.includes('ChatGPT'))).toBe(true)
|
|
640
|
+
expect(b.toasts.some(text => text.includes('missing-max'))).toBe(true)
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
for (const agentAuto of ['off', 'auto', 'all'] as const) test('only ripple:auto is registered, and takeover respects agentAuto ' + agentAuto, async ($, on) => {
|
|
644
|
+
const { registered } = boundaries(on, { agentAuto })
|
|
645
|
+
http(on)
|
|
646
|
+
const judges = judgeStub(on)
|
|
647
|
+
const sent = spawnStub(on)
|
|
648
|
+
await start($)
|
|
649
|
+
expect([...new Set(registered.map(e => e.name))]).toEqual(agentAuto === 'off' ? [] : ['auto'])
|
|
650
|
+
for (const entry of registered) {
|
|
651
|
+
expect(entry.description).toContain('low/medium/high/max')
|
|
652
|
+
expect(entry.disallowedTools).toEqual(['Agent'])
|
|
653
|
+
}
|
|
654
|
+
await $.agent.spawn(SPAWN)
|
|
655
|
+
expect(sent[0]?.subagentType).toBe(agentAuto === 'off' ? 'ripple:auto' : 'deepseek-v4-1-flash')
|
|
656
|
+
// A tier agent from a session started before 2026-10-12 is no longer taken over.
|
|
657
|
+
await $.agent.spawn({ ...SPAWN, subagentType: 'ripple:max' })
|
|
658
|
+
expect(sent[1]?.subagentType).toBe('ripple:max')
|
|
659
|
+
await $.agent.spawn({ ...SPAWN, fork: true })
|
|
660
|
+
expect(sent[2]?.subagentType).toBe('ripple:auto')
|
|
661
|
+
expect(judges.length).toBe(agentAuto === 'off' ? 0 : 1)
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
test('a rung\'s stored backup is dropped on load: ChatGPT exhaustion moves up, it does not run the backup', async ($, on) => {
|
|
665
|
+
const rules = normalizeSettings({ rules: { general: { mode: 'ladder', rungs: {
|
|
666
|
+
standard: { target: { agent: 'gpt-6-1-sol' }, effort: 'high', backup: { agent: 'general-purpose', model: 'opus' } },
|
|
667
|
+
hard: { target: { agent: 'gpt-6-astra' }, effort: 'xhigh' },
|
|
668
|
+
} } } }).rules
|
|
669
|
+
const b = boundaries(on, { rules })
|
|
670
|
+
http(on, true)
|
|
671
|
+
judgeStub(on, '{"domain":"backend","level":"standard","effort":"low","why":"implementation"}')
|
|
672
|
+
const sent = spawnStub(on)
|
|
673
|
+
await start($)
|
|
674
|
+
await $.agent.spawn(SPAWN)
|
|
675
|
+
expect(sent[0]).toMatchObject({ subagentType: 'general-purpose' })
|
|
676
|
+
expect(sent[0]?.model).toBeUndefined()
|
|
677
|
+
expect(b.toasts.some(t => t.includes('ChatGPT') && t.includes("the session's model"))).toBe(true)
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
test('without a backup, ChatGPT exhaustion moves one rung up, then to the session model', async ($, on) => {
|
|
681
|
+
const b = boundaries(on)
|
|
682
|
+
http(on, true)
|
|
683
|
+
judgeStub(on, '{"domain":"backend","level":"standard","effort":"high","why":"implementation"}')
|
|
684
|
+
const sent = spawnStub(on)
|
|
685
|
+
await start($)
|
|
686
|
+
await $.agent.spawn(SPAWN)
|
|
687
|
+
// Standard is Sol and Hard is Astra, both ChatGPT: the subagent runs on the session's model.
|
|
688
|
+
expect(sent[0]?.subagentType).toBe('general-purpose')
|
|
689
|
+
expect(sent[0]?.model).toBeUndefined()
|
|
690
|
+
expect(b.toasts.some(t => t.includes('ChatGPT') && t.includes("the session's model"))).toBe(true)
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
test('each spawn refreshes provider health before picking and toasts the exhaustion reason', async ($, on) => {
|
|
694
|
+
const b = boundaries(on, { language: 'ko' })
|
|
695
|
+
let exhausted = false
|
|
696
|
+
let reads = 0
|
|
697
|
+
on('http.fetch', { url: /\/api\/mod-options$/ }, () => {
|
|
698
|
+
reads++
|
|
699
|
+
const providers = MOD_OPTIONS.providers.map(p => p.name === 'deepseek' ? { ...p, usable: !exhausted, ...(exhausted ? { reason: 'insufficient_balance', exhaustedUntil: NOW + 1_800_000 } : {}) } : p)
|
|
700
|
+
return { value: { ok: true, status: 200, headers: {}, text: JSON.stringify({ ...MOD_OPTIONS, providers }) } }
|
|
701
|
+
})
|
|
702
|
+
http(on)
|
|
703
|
+
judgeStub(on)
|
|
704
|
+
const sent = spawnStub(on)
|
|
705
|
+
await start($)
|
|
706
|
+
await $.agent.spawn(SPAWN)
|
|
707
|
+
expect(sent[0]?.subagentType).toBe('deepseek-v4-1-flash')
|
|
708
|
+
const before = reads
|
|
709
|
+
exhausted = true
|
|
710
|
+
await $.agent.spawn(SPAWN)
|
|
711
|
+
expect(reads).toBeGreaterThan(before)
|
|
712
|
+
expect(sent[1]?.subagentType).toBe('gpt-6-1-sol')
|
|
713
|
+
expect(b.toasts.some(text => text.includes('deepseek: 잔액 부족'))).toBe(true)
|
|
714
|
+
exhausted = false
|
|
715
|
+
await $.agent.spawn(SPAWN)
|
|
716
|
+
expect(sent[2]?.subagentType).toBe('deepseek-v4-1-flash')
|
|
717
|
+
})
|
|
718
|
+
|
|
719
|
+
test('explicit generated worker preserves the old dropped-model and misplaced-marker fix', async ($, on) => {
|
|
720
|
+
const b = boundaries(on)
|
|
721
|
+
http(on)
|
|
722
|
+
const judges = judgeStub(on)
|
|
723
|
+
const sent = spawnStub(on)
|
|
724
|
+
await start($)
|
|
725
|
+
await $.agent.spawn({ ...SPAWN, subagentType: 'deepseek-v4-1-flash', model: 'sonnet', prompt: 'Context\n[[ripple: deepseek-v4-1-flash@max]]\nDo the work.' })
|
|
726
|
+
expect(judges.length).toBe(0)
|
|
727
|
+
expect(sent[0]?.model).toBe('deepseek-v4.1-flash@max')
|
|
728
|
+
expect(sent[0]?.prompt).not.toContain('[[ripple:')
|
|
729
|
+
expect(b.toasts.some(t => t.includes('own model') && t.includes('sonnet'))).toBe(true)
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
test('an explicit marker on ripple:auto bypasses its judge', async ($, on) => {
|
|
733
|
+
boundaries(on)
|
|
734
|
+
http(on)
|
|
735
|
+
const judges = judgeStub(on)
|
|
736
|
+
const sent = spawnStub(on)
|
|
737
|
+
await start($)
|
|
738
|
+
await $.agent.spawn({ ...SPAWN, prompt: '[[ripple: gpt-6-1-sol@xhigh]]\nReview this.' })
|
|
739
|
+
expect(judges.length).toBe(0)
|
|
740
|
+
expect(sent[0]).toMatchObject({ subagentType: 'gpt-6-1-sol', model: 'gpt-6.1-sol@xhigh' })
|
|
741
|
+
})
|
|
742
|
+
|
|
743
|
+
test('failed worker judge uses the standard rung; a missing worker moves up to the next rung', async ($, on) => {
|
|
744
|
+
const rules = normalizeSettings({ rules: { general: { mode: 'ladder', rungs: {
|
|
745
|
+
standard: { target: { agent: 'missing-worker' }, effort: 'medium' }, hard: { target: { agent: 'general-purpose', model: 'sonnet' }, effort: 'high' },
|
|
746
|
+
} } } }).rules
|
|
747
|
+
boundaries(on, { rules })
|
|
748
|
+
http(on, true)
|
|
749
|
+
on('model.complete', () => ({ value: { isAnswered: false, reason: 'empty-reply', usage: USAGE } }))
|
|
750
|
+
const sent = spawnStub(on)
|
|
751
|
+
await start($)
|
|
752
|
+
await $.agent.spawn(SPAWN)
|
|
753
|
+
expect(sent[0]).toMatchObject({ subagentType: 'general-purpose', model: 'sonnet' })
|
|
754
|
+
const debug = await $.command.run({ command: 'ripple', origin: { kind: 'composer' }, presentation: { isFullscreen: true, columns: 140 }, args: 'debug' })
|
|
755
|
+
expect(debug.text).toContain('"effort": "high"')
|
|
756
|
+
expect(debug.text).toContain('"rung": "hard"')
|
|
757
|
+
expect(debug.text).toContain('"by": "default"')
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
test('Zen Jev uses the exact config authorization and first Jev model with CLAUDERIPPLE_HOME', async ($, on) => {
|
|
761
|
+
const paths: string[] = []
|
|
762
|
+
const config = { ...CONFIG, providers: { ...CONFIG.providers, 'opencode-zen': { headers: { authorization: 'Bearer zen-config-key' }, models: [{ id: 'other' }, { id: 'jev-1.13' }, { id: 'jev-latest' }] } } }
|
|
763
|
+
boundaries(on, { judge: 'jev', jevSource: 'zen' }, { env: { CLAUDERIPPLE_HOME: '/custom/ripple' }, config, paths })
|
|
764
|
+
const sent: { url: string; body?: string; headers?: Readonly<Record<string, string>> }[] = []
|
|
765
|
+
http(on, false, config, e => sent.push({ url: e.url, body: e.init?.body, headers: e.init?.headers }))
|
|
766
|
+
const haiku = judgeStub(on)
|
|
767
|
+
await start($)
|
|
768
|
+
await submit($)
|
|
769
|
+
expect(sent.length).toBe(1)
|
|
770
|
+
expect(sent[0]?.headers?.authorization).toBe('Bearer zen-config-key')
|
|
771
|
+
expect(JSON.parse(sent[0]!.body!)).toMatchObject({ model: 'jev-1.13', questions: { effort: { type: 'choice' } } })
|
|
772
|
+
expect(haiku.length).toBe(0)
|
|
773
|
+
expect(paths).toContain('/custom/ripple/config.json')
|
|
774
|
+
await command($, 'settings')
|
|
775
|
+
const ui = await band($)
|
|
776
|
+
expect(await ui.find({ key: 'jev-key' })).toBeUndefined()
|
|
777
|
+
expect(await ui.find({ key: 'jev-test' })).toBeUndefined()
|
|
778
|
+
expect(await ui.find({ key: 'jev-source-zen' })).toBeUndefined()
|
|
779
|
+
await ui.unmount()
|
|
780
|
+
})
|
|
781
|
+
|
|
782
|
+
test('Zen without a key explains the missing key and falls back to Haiku', async ($, on) => {
|
|
783
|
+
const b = boundaries(on, { judge: 'jev', jevSource: 'zen' })
|
|
784
|
+
const urls = http(on)
|
|
785
|
+
const haiku = judgeStub(on, '{"effort":"medium","why":"fallback"}')
|
|
786
|
+
await start($)
|
|
787
|
+
await submit($)
|
|
788
|
+
expect(haiku.length).toBe(1)
|
|
789
|
+
expect(urls.some(url => url.includes('opencode.ai'))).toBe(false)
|
|
790
|
+
expect(b.toasts.some(text => text.includes('No OpenCode Zen authorization key'))).toBe(true)
|
|
791
|
+
})
|
|
792
|
+
|
|
793
|
+
test('provider off refuses named workers and every-provider-off refuses automatic spawning', async ($, on) => {
|
|
794
|
+
boundaries(on, { providers: { chatgpt: false, deepseek: false, anthropic: false } })
|
|
795
|
+
http(on)
|
|
796
|
+
judgeStub(on)
|
|
797
|
+
const sent = spawnStub(on)
|
|
798
|
+
await start($)
|
|
799
|
+
const explicit = await $.agent.spawn({ ...SPAWN, subagentType: 'gpt-6-1-sol' })
|
|
800
|
+
expect(explicit.deny).toContain('switched off')
|
|
801
|
+
const automatic = await $.agent.spawn(SPAWN)
|
|
802
|
+
expect(automatic.deny).toContain('No enabled worker')
|
|
803
|
+
expect(sent.length).toBe(0)
|
|
804
|
+
})
|
|
805
|
+
|
|
806
|
+
test('a subagent may not start a worker; the main chat still delegates', async ($, on) => {
|
|
807
|
+
boundaries(on)
|
|
808
|
+
http(on)
|
|
809
|
+
const judges = judgeStub(on)
|
|
810
|
+
const sent = spawnStub(on)
|
|
811
|
+
await start($)
|
|
812
|
+
const nested = await $.agent.spawn({ ...SPAWN, parentAgentId: 'agent-7' })
|
|
813
|
+
expect(nested.deny).toContain('may not start another subagent')
|
|
814
|
+
const named = await $.agent.spawn({ ...SPAWN, subagentType: 'gpt-6-1-sol', parentAgentId: 'agent-7' })
|
|
815
|
+
expect(named.deny).toContain('may not start another subagent')
|
|
816
|
+
expect(judges.length).toBe(0)
|
|
817
|
+
expect(sent.length).toBe(0)
|
|
818
|
+
expect((await $.agent.spawn(SPAWN)).agentId).toBe('worker-1')
|
|
819
|
+
})
|
|
820
|
+
|
|
821
|
+
test('prompt judge rewrites every Opus 5.5 main step, not subagent effort', async ($, on) => {
|
|
822
|
+
boundaries(on)
|
|
823
|
+
http(on)
|
|
824
|
+
const calls = judgeStub(on, '{"effort":"high","sure":0.9,"why":"multi step"}')
|
|
825
|
+
const sent = stepStub(on)
|
|
826
|
+
await start($)
|
|
827
|
+
await submit($)
|
|
828
|
+
await step($)
|
|
829
|
+
await step($)
|
|
830
|
+
await step($, 'claude-opus-5-5', 'low', 'untracked')
|
|
831
|
+
expect(calls.length).toBe(1)
|
|
832
|
+
expect(sent.map(s => s.effort)).toEqual(['high', 'high', 'low'])
|
|
833
|
+
const stats = await $.command.run({ command: 'ripple', origin: { kind: 'composer' }, presentation: { isFullscreen: true, columns: 140 }, args: 'stats' })
|
|
834
|
+
expect(stats.text).toContain('Auto: 1 prompts, 2 requests')
|
|
835
|
+
})
|
|
836
|
+
|
|
837
|
+
test('Fable pauses without paying a judge or rewriting effort', async ($, on) => {
|
|
838
|
+
const b = boundaries(on)
|
|
839
|
+
b.setModel('claude-fable-5-1')
|
|
840
|
+
http(on)
|
|
841
|
+
const calls = judgeStub(on)
|
|
842
|
+
const sent = stepStub(on)
|
|
843
|
+
await start($)
|
|
844
|
+
await submit($)
|
|
845
|
+
await step($, 'claude-fable-5-1', 'max')
|
|
846
|
+
expect(calls.length).toBe(0)
|
|
847
|
+
expect(sent[0]?.effort).toBe('max')
|
|
848
|
+
const debug = await $.command.run({ command: 'ripple', origin: { kind: 'composer' }, presentation: { isFullscreen: true, columns: 140 }, args: 'debug' })
|
|
849
|
+
expect(debug.text).toContain('"paused": true')
|
|
850
|
+
})
|
|
851
|
+
|
|
852
|
+
test('a later person app-effort change turns Auto off and wins', async ($, on) => {
|
|
853
|
+
const b = boundaries(on)
|
|
854
|
+
http(on)
|
|
855
|
+
judgeStub(on, '{"effort":"high","why":"scope"}')
|
|
856
|
+
const sent = stepStub(on)
|
|
857
|
+
await start($)
|
|
858
|
+
await submit($)
|
|
859
|
+
await step($, 'claude-opus-5-5', 'medium')
|
|
860
|
+
await step($, 'claude-opus-5-5', 'low')
|
|
861
|
+
expect(sent.map(s => s.effort)).toEqual(['high', 'low'])
|
|
862
|
+
expect(b.router().settings).toMatchObject({ effortAuto: false })
|
|
863
|
+
expect(b.toasts.some(t => t.includes('turned Auto off'))).toBe(true)
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
test('lower mid-turn pick waits until turn.complete', async ($, on) => {
|
|
867
|
+
boundaries(on)
|
|
868
|
+
http(on)
|
|
869
|
+
let judges = 0
|
|
870
|
+
on('model.complete', () => ({ value: { isAnswered: true, text: JSON.stringify({ effort: ++judges === 1 ? 'high' : 'low', why: 'scope' }), usage: USAGE } }))
|
|
871
|
+
const sent = stepStub(on)
|
|
872
|
+
await start($)
|
|
873
|
+
await submit($)
|
|
874
|
+
await step($)
|
|
875
|
+
await submit($, 'This is a simpler separate task now.', 'turn-1')
|
|
876
|
+
await step($)
|
|
877
|
+
await complete($)
|
|
878
|
+
await step($)
|
|
879
|
+
expect(sent.map(s => s.effort)).toEqual(['high', 'high', 'low'])
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
test('short followups retain the pick; host slash commands are not judged', async ($, on) => {
|
|
883
|
+
boundaries(on)
|
|
884
|
+
http(on)
|
|
885
|
+
const judges = judgeStub(on, '{"effort":"high","why":"scope"}')
|
|
886
|
+
stepStub(on)
|
|
887
|
+
await start($)
|
|
888
|
+
await submit($)
|
|
889
|
+
await submit($, 'go')
|
|
890
|
+
await submit($, '/clear')
|
|
891
|
+
expect(judges.length).toBe(1)
|
|
892
|
+
const result = await $.command.run({ command: 'ripple', origin: { kind: 'composer' }, presentation: { isFullscreen: true, columns: 140 }, args: 'stats' })
|
|
893
|
+
expect(result.text).toContain('Auto: 2 prompts')
|
|
894
|
+
})
|
|
895
|
+
|
|
896
|
+
test('keep-warm forks near expiry, refreshes the memo and stops once on a null fork', async ($, on) => {
|
|
897
|
+
const b = boundaries(on, { keepWarm: true })
|
|
898
|
+
http(on)
|
|
899
|
+
judgeStub(on)
|
|
900
|
+
stepStub(on)
|
|
901
|
+
let forks = 0
|
|
902
|
+
// null is a measured legacy failure; current declarations exclude it, so this boundary stub
|
|
903
|
+
// deliberately returns it through an unchecked value (not a cast in production code).
|
|
904
|
+
on('model.fork', () => ({ value: ++forks === 1 ? { isAnswered: true, text: 'ok', usage: USAGE } : JSON.parse('null') }))
|
|
905
|
+
await start($)
|
|
906
|
+
await step($)
|
|
907
|
+
await complete($)
|
|
908
|
+
await b.clock.advance(3375000)
|
|
909
|
+
expect(forks).toBe(1)
|
|
910
|
+
const first = await $.command.run({ command: 'ripple', origin: { kind: 'composer' }, presentation: { isFullscreen: true, columns: 140 }, args: 'debug' })
|
|
911
|
+
expect(first.text).toContain('"pings": 1')
|
|
912
|
+
await b.clock.advance(3375000)
|
|
913
|
+
expect(forks).toBe(2)
|
|
914
|
+
await b.clock.advance(120000)
|
|
915
|
+
expect(forks).toBe(2)
|
|
916
|
+
expect(b.toasts.filter(t => t.includes('Keep warm stopped')).length).toBe(1)
|
|
917
|
+
const stats = await $.command.run({ command: 'ripple', origin: { kind: 'composer' }, presentation: { isFullscreen: true, columns: 140 }, args: 'stats' })
|
|
918
|
+
expect(stats.text).toContain('Warm: 1 pings')
|
|
919
|
+
})
|
|
920
|
+
|
|
921
|
+
test('fork turn.step is not effort-steered, busy or main-request cost', async ($, on) => {
|
|
922
|
+
const b = boundaries(on, { keepWarm: true })
|
|
923
|
+
http(on)
|
|
924
|
+
judgeStub(on, '{"effort":"high","why":"scope"}')
|
|
925
|
+
const sent = stepStub(on)
|
|
926
|
+
let forks = 0
|
|
927
|
+
on('model.fork', async () => {
|
|
928
|
+
forks++
|
|
929
|
+
await step($, 'claude-opus-5-5', 'medium')
|
|
930
|
+
return { value: { isAnswered: true, text: 'ok', usage: USAGE } }
|
|
931
|
+
})
|
|
932
|
+
await start($)
|
|
933
|
+
await submit($)
|
|
934
|
+
await step($)
|
|
935
|
+
await complete($)
|
|
936
|
+
await b.clock.advance(3375000)
|
|
937
|
+
expect(forks).toBe(1)
|
|
938
|
+
expect(sent.map(s => s.effort)).toEqual(['high', 'medium'])
|
|
939
|
+
const stats = await $.command.run({ command: 'ripple', origin: { kind: 'composer' }, presentation: { isFullscreen: true, columns: 140 }, args: 'stats' })
|
|
940
|
+
expect(stats.text).toContain('Auto: 1 prompts, 1 requests')
|
|
941
|
+
expect(stats.text).toContain('Warm: 1 pings')
|
|
942
|
+
})
|
|
943
|
+
|
|
944
|
+
test('Haiku compaction returns replacement messages and forwards the keep note', async ($, on) => {
|
|
945
|
+
boundaries(on)
|
|
946
|
+
http(on)
|
|
947
|
+
const calls = judgeStub(on, 'Verified continuation summary. '.repeat(20))
|
|
948
|
+
let engine = 0
|
|
949
|
+
on('session.compact', () => { engine++; return { messages: MESSAGES } })
|
|
950
|
+
await start($)
|
|
951
|
+
const out = await $.session.compact({ trigger: 'manual', messages: MESSAGES, instructions: 'Keep file paths.' })
|
|
952
|
+
expect(calls[0]).toMatchObject({ model: 'claude-haiku-5-5', maxTokens: 16000, timeoutMs: 240000, effort: 'medium' })
|
|
953
|
+
expect(calls[0]?.prompt).toContain('Keep file paths.')
|
|
954
|
+
expect(out.messages?.[0]?.text.includes('Continuation record')).toBe(true)
|
|
955
|
+
expect(engine).toBe(0)
|
|
956
|
+
})
|
|
957
|
+
|
|
958
|
+
test('failed Haiku compaction falls back; precompute skips; subagent stays with engine', async ($, on) => {
|
|
959
|
+
const b = boundaries(on)
|
|
960
|
+
http(on)
|
|
961
|
+
const calls = judgeStub(on, 'too short')
|
|
962
|
+
let engine = 0
|
|
963
|
+
on('session.compact', () => { engine++; return { messages: MESSAGES } })
|
|
964
|
+
await start($)
|
|
965
|
+
await $.session.compact({ trigger: 'manual', messages: MESSAGES })
|
|
966
|
+
const skipped = await $.session.compact({ trigger: 'precompute', messages: MESSAGES })
|
|
967
|
+
await $.session.compact({ trigger: 'auto', messages: MESSAGES, agentId: 'worker' })
|
|
968
|
+
expect(engine).toBe(2)
|
|
969
|
+
expect(calls.length).toBe(1)
|
|
970
|
+
expect('skip' in skipped).toBe(true)
|
|
971
|
+
expect(b.toasts.some(t => t.includes('session compactor'))).toBe(true)
|
|
972
|
+
})
|
|
973
|
+
|
|
974
|
+
// ---- Operating modes (docs/specs/2026-10-12-worker-tiers.md "Operating modes") -----------------
|
|
975
|
+
|
|
976
|
+
const BURN = normalizeModes([{ id: 'burn', name: 'Burn GPT', rungs: { ...DEFAULTS.rules.general.rungs, light: [{ target: { agent: 'gpt-6-1-sol' }, effort: 'low' }] }, delegation: 'eager' }])
|
|
977
|
+
|
|
978
|
+
test('an eager mode notes the main chat once, and again after a compaction; its tiers route spawns and each pick names it', async ($, on) => {
|
|
979
|
+
const b = boundaries(on, { modes: BURN, activeMode: 'burn', activeSince: NOW, compactWith: 'session' })
|
|
980
|
+
http(on)
|
|
981
|
+
judgeStub(on)
|
|
982
|
+
const sent = spawnStub(on)
|
|
983
|
+
on('session.compact', () => ({ messages: MESSAGES }))
|
|
984
|
+
await start($)
|
|
985
|
+
await submit($)
|
|
986
|
+
await submit($)
|
|
987
|
+
// A slash command is not the user's prompt: no note, and the turn after it is still noted.
|
|
988
|
+
await submit($, '/model')
|
|
989
|
+
await $.session.compact({ trigger: 'auto', messages: MESSAGES })
|
|
990
|
+
// A subagent's compaction is not the main chat's.
|
|
991
|
+
await submit($)
|
|
992
|
+
await $.session.compact({ trigger: 'auto', messages: MESSAGES, agentId: 'worker' })
|
|
993
|
+
await submit($)
|
|
994
|
+
const note = 'ClaudeRipple mode Burn GPT: delegate everything except judgment and final review to ripple:auto.'
|
|
995
|
+
expect(b.contexts).toEqual([[note], [], [], [note], []])
|
|
996
|
+
// Prefer ChatGPT: Low leaves DeepSeek for the cheaper of Sol and Astra (AA cost), at Low's effort.
|
|
997
|
+
await $.agent.spawn(SPAWN)
|
|
998
|
+
expect(sent[0]).toMatchObject({ subagentType: 'gpt-6-1-sol', model: 'gpt-6.1-sol@low' })
|
|
999
|
+
const pick = b.logs.find(text => text.startsWith('ClaudeRipple worker pick: '))!
|
|
1000
|
+
expect(JSON.parse(pick.slice('ClaudeRipple worker pick: '.length))).toMatchObject({ level: 'light', rung: 'light', mode: 'Burn GPT' })
|
|
1001
|
+
// The band names the mode.
|
|
1002
|
+
const ui = await band($, 'desktop')
|
|
1003
|
+
expect(await ui.find({ type: 'Text', text: 'Burn GPT' })).toBeDefined()
|
|
1004
|
+
})
|
|
1005
|
+
|
|
1006
|
+
test('with no mode on, nothing is attached and picks name none', async ($, on) => {
|
|
1007
|
+
const b = boundaries(on, { modes: BURN })
|
|
1008
|
+
http(on)
|
|
1009
|
+
judgeStub(on)
|
|
1010
|
+
spawnStub(on)
|
|
1011
|
+
await start($)
|
|
1012
|
+
await submit($)
|
|
1013
|
+
await $.agent.spawn(SPAWN)
|
|
1014
|
+
expect(b.contexts).toEqual([[]])
|
|
1015
|
+
expect(JSON.parse(b.logs.find(text => text.startsWith('ClaudeRipple worker pick: '))!.slice('ClaudeRipple worker pick: '.length)).mode).toBeUndefined()
|
|
1016
|
+
})
|
|
1017
|
+
|
|
1018
|
+
test('a mode ends at its time and says so once; quick settings turn one on and off', async ($, on) => {
|
|
1019
|
+
const modes = normalizeModes([{ id: 'hour', name: 'One minute', targets: ['chatgpt'], until: NOW + 60_000 }, ...BURN])
|
|
1020
|
+
const b = boundaries(on, { modes, activeMode: 'hour', activeSince: NOW })
|
|
1021
|
+
http(on)
|
|
1022
|
+
await start($)
|
|
1023
|
+
await b.clock.advance(30_000)
|
|
1024
|
+
expect(b.router().settings.activeMode).toBe('hour')
|
|
1025
|
+
await b.clock.advance(40_000)
|
|
1026
|
+
expect(b.router().settings.activeMode).toBeUndefined()
|
|
1027
|
+
expect(b.router().settings.modes).toEqual(modes)
|
|
1028
|
+
expect(b.toasts.filter(text => text === 'The One minute mode has ended.').length).toBe(1)
|
|
1029
|
+
await b.clock.advance(9000)
|
|
1030
|
+
expect(b.toasts.filter(text => text === 'The One minute mode has ended.').length).toBe(1)
|
|
1031
|
+
// The quick settings' one mode control (the desktop's Mode tile, a drawn segment each): none,
|
|
1032
|
+
// then each mode.
|
|
1033
|
+
await command($, 'settings')
|
|
1034
|
+
const ui = await band($, 'desktop')
|
|
1035
|
+
const drawn = String((await ui.findAll({ type: 'Svg' })).map(svg => svg.props.alt).find(alt => String(alt).includes('Burn GPT')))
|
|
1036
|
+
for (const label of ['Base', 'One minute', 'Burn GPT']) expect(drawn, label).toContain(label)
|
|
1037
|
+
await ui.press({ key: 'mode-burn' })
|
|
1038
|
+
expect([b.router().settings.activeMode, b.router().settings.activeSince]).toEqual(['burn', NOW + 79_000])
|
|
1039
|
+
await ui.press({ key: 'mode-' })
|
|
1040
|
+
expect(Object.hasOwn(b.router().settings, 'activeMode')).toBe(false)
|
|
1041
|
+
})
|
|
1042
|
+
|
|
1043
|
+
test('a mode ends at the first ChatGPT reset after activation', async ($, on) => {
|
|
1044
|
+
const modes = normalizeModes([{ id: 'burn', name: 'Burn GPT', rungs: { light: [{ target: { agent: 'gpt-6-astra' }, effort: 'high' }] }, until: { reset: 'chatgpt' } }])
|
|
1045
|
+
const b = boundaries(on, { modes, activeMode: 'burn', activeSince: NOW })
|
|
1046
|
+
const asked: string[] = []
|
|
1047
|
+
on('http.fetch', { url: /\/api\/chatgpt-accounts/ }, ($, e) => {
|
|
1048
|
+
asked.push(e.url)
|
|
1049
|
+
const accounts = [{ state: 'ready', quota: { at: NOW, rate_limits: { primary: { used_percent: 10, window_minutes: 300, reset_at: (NOW + 30_000) / 1000 }, secondary: { used_percent: 60, window_minutes: 10080, reset_at: (NOW + 150_000) / 1000 } } } }]
|
|
1050
|
+
return { value: { ok: true, status: 200, headers: {}, text: JSON.stringify({ provider: 'chatgpt', accounts }) } }
|
|
1051
|
+
})
|
|
1052
|
+
http(on)
|
|
1053
|
+
await start($)
|
|
1054
|
+
await b.clock.advance(20_000)
|
|
1055
|
+
expect(b.router().settings.activeMode).toBe('burn')
|
|
1056
|
+
await b.clock.advance(20_000)
|
|
1057
|
+
expect(b.router().settings.activeMode).toBeUndefined()
|
|
1058
|
+
expect(b.toasts).toContain('The Burn GPT mode has ended.')
|
|
1059
|
+
// The quota is read at most once a minute, for the provider the target model belongs to.
|
|
1060
|
+
expect(asked.length).toBeLessThanOrEqual(4)
|
|
1061
|
+
expect(asked.every(url => url.endsWith('/api/chatgpt-accounts?provider=chatgpt'))).toBe(true)
|
|
1062
|
+
})
|
|
1063
|
+
|
|
1064
|
+
for (const binding of ['five_hour', 'seven_day']) test('reset mode follows the band binding Claude limit: ' + binding, async ($, on) => {
|
|
1065
|
+
// A ChatGPT worker in the lists does not make ChatGPT's earlier reset end a Claude-reset mode.
|
|
1066
|
+
const rungs = { light: [{ target: { agent: 'gpt-6-astra' }, effort: 'high' }, { target: { agent: 'general-purpose', model: 'opus' }, effort: 'high' }] }
|
|
1067
|
+
const modes = normalizeModes([{ id: 'claude', name: 'Claude work', rungs, until: { reset: 'anthropic' } }])
|
|
1068
|
+
const limits = ['five_hour', 'seven_day'].map(kind => ({ kind, percentUsed: kind === binding ? 90 : 20, resetsAt: new Date(NOW + (kind === binding ? 30_000 : 10_000)).toISOString() }))
|
|
1069
|
+
const b = boundaries(on, { modes, activeMode: 'claude', activeSince: NOW }, { limits })
|
|
1070
|
+
const asked: string[] = []
|
|
1071
|
+
on('http.fetch', { url: /\/api\/chatgpt-accounts/ }, ($, e) => {
|
|
1072
|
+
asked.push(e.url)
|
|
1073
|
+
const accounts = [{ state: 'ready', quota: { at: NOW, rate_limits: { primary: { used_percent: 10, window_minutes: 300, reset_at: (NOW + 5_000) / 1000 } } } }]
|
|
1074
|
+
return { value: { ok: true, status: 200, headers: {}, text: JSON.stringify({ provider: 'chatgpt', accounts }) } }
|
|
1075
|
+
})
|
|
1076
|
+
http(on)
|
|
1077
|
+
await start($)
|
|
1078
|
+
await b.clock.advance(20_000)
|
|
1079
|
+
expect(b.router().settings.activeMode).toBe('claude')
|
|
1080
|
+
await b.clock.advance(20_000)
|
|
1081
|
+
expect(b.router().settings.activeMode).toBeUndefined()
|
|
1082
|
+
expect(b.toasts.filter(text => text === 'The Claude work mode has ended.').length).toBe(1)
|
|
1083
|
+
expect(asked).toEqual([])
|
|
1084
|
+
})
|
|
1085
|
+
|
|
1086
|
+
test('/ripple stats returns plain measured text', async ($, on) => {
|
|
1087
|
+
boundaries(on)
|
|
1088
|
+
const out = await $.command.run({ command: 'ripple', origin: { kind: 'composer' }, presentation: { isFullscreen: true, columns: 140 }, args: 'stats' })
|
|
1089
|
+
expect(out.text).toContain('Auto: 0 prompts, 0 requests; 0 weighted tokens')
|
|
1090
|
+
expect(out.text).toContain('Workers: light 0, standard 0, deep 0, design 0')
|
|
1091
|
+
})
|
|
1092
|
+
|
|
1093
|
+
for (const surface of ['terminal', 'desktop'] as const) test('quick controls save immediately on ' + surface + ' and Close keeps the changes', async ($, on) => {
|
|
1094
|
+
const b = boundaries(on)
|
|
1095
|
+
const urls = http(on)
|
|
1096
|
+
await start($)
|
|
1097
|
+
const ui = await band($, surface)
|
|
1098
|
+
expect(await ui.find({ type: 'Text', text: 'ClaudeRipple' })).toBeUndefined()
|
|
1099
|
+
await ui.press({ key: 'settings' })
|
|
1100
|
+
expect(await ui.find({ type: 'Text', text: 'Quick settings' })).toBeDefined()
|
|
1101
|
+
expect(await ui.find({ key: 'save' })).toBeUndefined()
|
|
1102
|
+
const puts = () => b.settingsRequests.filter(r => r.method === 'PUT').length
|
|
1103
|
+
let count = puts()
|
|
1104
|
+
await ui.press({ key: 'bias-2' })
|
|
1105
|
+
expect(b.router().settings.bias).toBe(2)
|
|
1106
|
+
expect(puts()).toBe(++count)
|
|
1107
|
+
await ui.press({ key: 'set-auto' })
|
|
1108
|
+
expect(b.router().settings.effortAuto).toBe(false)
|
|
1109
|
+
expect(puts()).toBe(++count)
|
|
1110
|
+
expect(await ui.find({ key: 'bias-2' })).toBeUndefined()
|
|
1111
|
+
await ui.press({ key: 'set-auto' })
|
|
1112
|
+
expect(b.router().settings.effortAuto).toBe(true)
|
|
1113
|
+
expect(puts()).toBe(++count)
|
|
1114
|
+
for (const judge of ['jev', 'model', 'haiku'] as const) {
|
|
1115
|
+
await ui.press({ key: 'judge-' + judge })
|
|
1116
|
+
expect(b.router().settings.judge).toBe(judge)
|
|
1117
|
+
expect(puts()).toBe(++count)
|
|
1118
|
+
}
|
|
1119
|
+
await ui.press({ key: 'set-warm' })
|
|
1120
|
+
expect(b.router().settings.keepWarm).toBe(true)
|
|
1121
|
+
expect(puts()).toBe(++count)
|
|
1122
|
+
await ui.press({ key: 'close' })
|
|
1123
|
+
expect(puts()).toBe(count)
|
|
1124
|
+
expect(b.router().settings).toMatchObject({ bias: 2, effortAuto: true, judge: 'haiku', keepWarm: true })
|
|
1125
|
+
// Opening the panel reads the rung choices once, so a model ticked on the dashboard is offered.
|
|
1126
|
+
expect(urls.filter(url => url.endsWith('/api/mod-options')).length).toBe(1)
|
|
1127
|
+
await ui.press({ key: 'hide' })
|
|
1128
|
+
expect(b.saved.get('bandHidden')).toBe(true)
|
|
1129
|
+
await $.command.run({ command: 'ripple-bar', origin: { kind: 'composer' }, presentation: { isFullscreen: true, columns: 140 }, args: '' })
|
|
1130
|
+
expect(b.saved.get('bandHidden')).toBe(false)
|
|
1131
|
+
await ui.unmount()
|
|
1132
|
+
})
|
|
1133
|
+
|
|
1134
|
+
for (const surface of ['terminal', 'desktop'] as const) test('quick settings select a saved mode, never edit rungs, on ' + surface, async ($, on) => {
|
|
1135
|
+
const b = boundaries(on, { modes: BURN })
|
|
1136
|
+
http(on)
|
|
1137
|
+
await start($)
|
|
1138
|
+
const ui = await band($, surface)
|
|
1139
|
+
await ui.press({ key: 'settings' })
|
|
1140
|
+
// The terminal's Select; the desktop's Mode tile, a drawn segment per mode.
|
|
1141
|
+
if (surface === 'desktop') {
|
|
1142
|
+
const modes = String((await ui.findAll({ type: 'Svg' })).map(svg => svg.props.alt).find(alt => String(alt).includes('Burn GPT')))
|
|
1143
|
+
for (const label of ['Base', 'Burn GPT']) expect(modes, label).toContain(label)
|
|
1144
|
+
for (const value of ['', 'burn']) expect(await ui.find({ type: 'Button', key: 'mode-' + value }), value).toBeDefined()
|
|
1145
|
+
} else {
|
|
1146
|
+
const control = await ui.find({ key: 'mode' })
|
|
1147
|
+
expect(control?.type).toBe('Select')
|
|
1148
|
+
expect((control?.props.options as { label: string }[]).map(o => o.label)).toEqual(['Base', 'Burn GPT'])
|
|
1149
|
+
}
|
|
1150
|
+
for (const rung of ['light', 'standard', 'hard', 'plan']) expect(await ui.find({ key: 'rung-' + rung })).toBeUndefined()
|
|
1151
|
+
const pick = (value: string) => surface === 'desktop' ? ui.press({ key: 'mode-' + value }) : ui.select({ key: 'mode', value })
|
|
1152
|
+
await pick('burn')
|
|
1153
|
+
expect(b.router().settings.activeMode).toBe('burn')
|
|
1154
|
+
expect(b.router().settings.rules).toEqual(DEFAULTS.rules)
|
|
1155
|
+
await pick('')
|
|
1156
|
+
expect(b.router().settings.activeMode).toBeUndefined()
|
|
1157
|
+
await ui.unmount()
|
|
1158
|
+
})
|
|
1159
|
+
|
|
1160
|
+
test('quick settings show Base even before any mode is saved', async ($, on) => {
|
|
1161
|
+
boundaries(on)
|
|
1162
|
+
http(on)
|
|
1163
|
+
await start($)
|
|
1164
|
+
const ui = await band($)
|
|
1165
|
+
await ui.press({ key: 'settings' })
|
|
1166
|
+
const mode = await ui.find({ key: 'mode' })
|
|
1167
|
+
expect((mode?.props.options as { label: string }[]).map(o => o.label)).toEqual(['Base'])
|
|
1168
|
+
await ui.unmount()
|
|
1169
|
+
})
|
|
1170
|
+
|
|
1171
|
+
for (const platform of ['Darwin', 'Windows_NT', 'Linux'] as const) test('all settings opens the mod dashboard on ' + platform, async ($, on) => {
|
|
1172
|
+
const b = boundaries(on, { language: 'en' }, { platform, env: platform === 'Windows_NT' ? { OS: 'Windows_NT' } : {} })
|
|
1173
|
+
http(on)
|
|
1174
|
+
await start($)
|
|
1175
|
+
await command($, 'settings')
|
|
1176
|
+
const ui = await band($)
|
|
1177
|
+
await ui.press({ key: 'all-settings' })
|
|
1178
|
+
const url = 'http://127.0.0.1:9992/#mod'
|
|
1179
|
+
const argv = platform === 'Darwin' ? ['open', url] : platform === 'Windows_NT' ? ['cmd', '/c', 'start', '', url] : ['xdg-open', url]
|
|
1180
|
+
expect(b.processes.map(p => p.argv)).toEqual(platform === 'Windows_NT' ? [argv] : [['uname', '-s'], argv])
|
|
1181
|
+
expect(b.toasts).toEqual([])
|
|
1182
|
+
expect(b.settingsRequests.filter(r => r.method === 'PUT').length).toBe(0)
|
|
1183
|
+
await ui.unmount()
|
|
1184
|
+
})
|
|
1185
|
+
|
|
1186
|
+
for (const failure of ['exit', 'throw'] as const) test('a browser ' + failure + ' failure toasts the dashboard URL', async ($, on) => {
|
|
1187
|
+
const b = boundaries(on, { language: 'en' }, { platform: 'Darwin', browserFailure: failure })
|
|
1188
|
+
http(on)
|
|
1189
|
+
await start($)
|
|
1190
|
+
await command($, 'settings')
|
|
1191
|
+
const ui = await band($)
|
|
1192
|
+
await ui.press({ key: 'all-settings' })
|
|
1193
|
+
expect(b.toasts).toContain('http://127.0.0.1:9992/#mod')
|
|
1194
|
+
expect(b.logs.some(log => log.includes('open mod settings'))).toBe(true)
|
|
1195
|
+
await ui.unmount()
|
|
1196
|
+
})
|
|
1197
|
+
|
|
1198
|
+
test('host survey wins; minimal layout renders through SessionMode', async ($, on) => {
|
|
1199
|
+
boundaries(on, { layout: 'minimal' })
|
|
1200
|
+
http(on)
|
|
1201
|
+
await start($)
|
|
1202
|
+
const survey = await band($, 'terminal', true)
|
|
1203
|
+
expect(await survey.find({ type: 'Text', text: 'host drawing' })).toBeDefined()
|
|
1204
|
+
await survey.unmount()
|
|
1205
|
+
const above = await band($)
|
|
1206
|
+
expect(await above.find({ key: 'auto' })).toBeUndefined()
|
|
1207
|
+
const footer = await $.ui.mount({ plugin: 'ripple', component: 'SessionMode', requestId: 'footer', surface: 'terminal', props: { modes: ['plan'] }, viewport: { columns: 140, rows: 40 } })
|
|
1208
|
+
expect(await footer.find({ key: 'auto' })).toBeDefined()
|
|
1209
|
+
expect(await footer.find({ type: 'Text', text: 'host drawing' })).toBeDefined()
|
|
1210
|
+
})
|
|
1211
|
+
|
|
1212
|
+
test('log queries by session and reopening follows engine panes, not stale atoms', async ($, on) => {
|
|
1213
|
+
const b = boundaries(on)
|
|
1214
|
+
const urls = http(on)
|
|
1215
|
+
await start($)
|
|
1216
|
+
await $.command.run({ command: 'ripple-log', origin: { kind: 'composer' }, presentation: { isFullscreen: true, columns: 140 }, args: '' })
|
|
1217
|
+
expect(urls.some(url => url.includes('session=session-test'))).toBe(true)
|
|
1218
|
+
expect(b.panes.has('ripple-log')).toBe(true)
|
|
1219
|
+
b.panes.clear() // Host reload closed the pane without ui.close.
|
|
1220
|
+
await $.command.run({ command: 'ripple-log', origin: { kind: 'composer' }, presentation: { isFullscreen: true, columns: 140 }, args: '' })
|
|
1221
|
+
expect(b.panes.has('ripple-log')).toBe(true)
|
|
1222
|
+
})
|
|
1223
|
+
|
|
1224
|
+
test('clear reloads router settings and resume restores cache age', async ($, on) => {
|
|
1225
|
+
const b = boundaries(on, { effortAuto: false })
|
|
1226
|
+
http(on)
|
|
1227
|
+
await $.classic.SessionStart({ source: 'resume', seconds_since_last_response: 120, prompt_cache_likely_expired: false, model: 'claude-opus-5-5' })
|
|
1228
|
+
const out = await $.command.run({ command: 'ripple', origin: { kind: 'composer' }, presentation: { isFullscreen: true, columns: 140 }, args: 'debug' })
|
|
1229
|
+
expect(out.text).toContain('"lastReplyAt": 880000')
|
|
1230
|
+
await $.classic.SessionStart({ source: 'clear' })
|
|
1231
|
+
const ui = await band($)
|
|
1232
|
+
expect((await ui.find({ key: 'auto' }))?.props.label).toContain('○')
|
|
1233
|
+
expect(b.logs).toEqual([])
|
|
1234
|
+
})
|
|
1235
|
+
|
|
1236
|
+
test('Save caps a high judge pick until the reported limit resets', async ($, on) => {
|
|
1237
|
+
const b = boundaries(on, {}, { limits: [{ kind: 'five_hour', percentUsed: 86, resetsAt: new Date(NOW + 60000).toISOString() }] })
|
|
1238
|
+
http(on)
|
|
1239
|
+
judgeStub(on, '{"effort":"max","sure":0.9,"why":"hard"}')
|
|
1240
|
+
const sent = stepStub(on)
|
|
1241
|
+
await start($)
|
|
1242
|
+
await b.clock.advance(1)
|
|
1243
|
+
await command($, 'save')
|
|
1244
|
+
await submit($)
|
|
1245
|
+
await step($)
|
|
1246
|
+
await complete($)
|
|
1247
|
+
await b.clock.advance(60000)
|
|
1248
|
+
await submit($)
|
|
1249
|
+
await step($)
|
|
1250
|
+
expect(sent.map(s => s.effort)).toEqual(['medium', 'max'])
|
|
1251
|
+
})
|
|
1252
|
+
|
|
1253
|
+
test('an unsure Jev still decides: no Haiku call, no alert, key only to TypeSafe', async ($, on) => {
|
|
1254
|
+
const b = boundaries(on, { judge: 'jev' }, { key: 'test-secret' })
|
|
1255
|
+
const judges = judgeStub(on, '{"effort":"high","why":"fallback"}')
|
|
1256
|
+
const sent = stepStub(on)
|
|
1257
|
+
const destinations: string[] = []
|
|
1258
|
+
on('http.fetch', ($, e, next) => {
|
|
1259
|
+
if (e.url.endsWith('/api/mod-settings')) return next(e)
|
|
1260
|
+
if (e.init?.headers?.authorization) destinations.push(e.url)
|
|
1261
|
+
if (e.url === 'https://api.typesafe.ai/v1/systemone') {
|
|
1262
|
+
expect(e.init?.headers?.authorization).toBe('Bearer test-secret')
|
|
1263
|
+
const body = JSON.parse(e.init?.body ?? '{}')
|
|
1264
|
+
expect(body.questions.effort.type).toBe('choice')
|
|
1265
|
+
return { value: { ok: true, status: 200, headers: {}, text: JSON.stringify({ answers: { effort: { choice: 'low', confidence: 0.2 } } }) } }
|
|
1266
|
+
}
|
|
1267
|
+
return { value: { ok: true, status: 200, headers: {}, text: '{"requests":[]}' } }
|
|
1268
|
+
})
|
|
1269
|
+
await start($)
|
|
1270
|
+
await submit($)
|
|
1271
|
+
await step($)
|
|
1272
|
+
expect(judges.length).toBe(0)
|
|
1273
|
+
// Torn at 0.2 with no odds given, the session keeps the effort it runs at.
|
|
1274
|
+
expect(sent[0]?.effort).toBe('medium')
|
|
1275
|
+
expect(destinations).toEqual(['https://api.typesafe.ai/v1/systemone'])
|
|
1276
|
+
// An unsure answer is not a judge failure: no toast, no alert card.
|
|
1277
|
+
expect(b.toasts).toEqual([])
|
|
1278
|
+
expect(await (await band($)).find({ type: 'Text', text: /not answering/ })).toBeUndefined()
|
|
1279
|
+
})
|
|
1280
|
+
|
|
1281
|
+
test('Jev times out at three seconds without blocking the person prompt, and says so only the second time running', async ($, on) => {
|
|
1282
|
+
const b = boundaries(on, { judge: 'jev' }, { key: 'test-secret' })
|
|
1283
|
+
const judges = judgeStub(on, '{"effort":"medium","why":"fallback"}')
|
|
1284
|
+
on('http.fetch', async ($, e, next) => {
|
|
1285
|
+
if (e.url.endsWith('/api/mod-settings')) return next(e)
|
|
1286
|
+
if (e.url.includes('typesafe.ai')) await b.clock.sleep(6000)
|
|
1287
|
+
return { value: { ok: true, status: 200, headers: {}, text: '{"requests":[]}' } }
|
|
1288
|
+
})
|
|
1289
|
+
await start($)
|
|
1290
|
+
const pending = submit($)
|
|
1291
|
+
await b.clock.settle()
|
|
1292
|
+
await b.clock.advance(3000)
|
|
1293
|
+
const out = await pending
|
|
1294
|
+
expect(out.text).toContain('Implement')
|
|
1295
|
+
expect(judges.length).toBe(1)
|
|
1296
|
+
// One slow answer is a bad moment: Haiku covered it and nothing is said.
|
|
1297
|
+
expect(b.toasts.some(t => t.includes('timed out'))).toBe(false)
|
|
1298
|
+
const again = submit($, 'Refactor the parser across three files and verify.')
|
|
1299
|
+
await b.clock.settle()
|
|
1300
|
+
await b.clock.advance(3000)
|
|
1301
|
+
await again
|
|
1302
|
+
expect(b.toasts.some(t => t.includes('timed out'))).toBe(true)
|
|
1303
|
+
})
|
|
1304
|
+
|
|
1305
|
+
test('a dropped Jev connection says it in plain words, and only the second time running', async ($, on) => {
|
|
1306
|
+
const b = boundaries(on, { judge: 'jev' }, { key: 'test-secret' })
|
|
1307
|
+
const judges = judgeStub(on, '{"effort":"medium","why":"fallback"}')
|
|
1308
|
+
on('http.fetch', ($, e, next) => {
|
|
1309
|
+
if (e.url.endsWith('/api/mod-settings')) return next(e)
|
|
1310
|
+
if (e.url.includes('typesafe.ai')) throw new Error('ripple: $.http.fetch(https://api.typesafe.ai/v1/systemone) failed: ECONNRESET: The socket connection was closed unexpectedly.')
|
|
1311
|
+
return { value: { ok: true, status: 200, headers: {}, text: '{"requests":[]}' } }
|
|
1312
|
+
})
|
|
1313
|
+
await start($)
|
|
1314
|
+
await submit($)
|
|
1315
|
+
expect(judges.length).toBe(1)
|
|
1316
|
+
expect(b.toasts).toEqual([])
|
|
1317
|
+
await submit($, 'Refactor the parser across three files and verify.')
|
|
1318
|
+
expect(b.toasts.some(t => t.includes('network unavailable'))).toBe(true)
|
|
1319
|
+
expect(b.toasts.some(t => t.includes('ECONNRESET'))).toBe(false)
|
|
1320
|
+
// The card blames the connection, not the judge.
|
|
1321
|
+
const ui = await band($)
|
|
1322
|
+
expect(await ui.find({ type: 'Text', text: /connection is unsteady/ })).toBeDefined()
|
|
1323
|
+
expect(await ui.find({ type: 'Text', text: /not answering/ })).toBeUndefined()
|
|
1324
|
+
})
|
|
1325
|
+
|
|
1326
|
+
test('router judge falls back and Haiku retries its alias when unanswered', async ($, on) => {
|
|
1327
|
+
boundaries(on, { judge: 'model', judgeModel: 'test-router-model' })
|
|
1328
|
+
http(on)
|
|
1329
|
+
const models: string[] = []
|
|
1330
|
+
on('model.complete', ($, e) => {
|
|
1331
|
+
models.push(e.model)
|
|
1332
|
+
return { value: e.model === 'haiku' ? { isAnswered: true, text: '{"effort":"low","why":"simple"}', usage: USAGE } : { isAnswered: false, reason: 'empty-reply', usage: USAGE } }
|
|
1333
|
+
})
|
|
1334
|
+
const sent = stepStub(on)
|
|
1335
|
+
await start($)
|
|
1336
|
+
await submit($)
|
|
1337
|
+
await step($)
|
|
1338
|
+
expect(models).toEqual(['test-router-model', 'claude-haiku-5-5', 'haiku'])
|
|
1339
|
+
expect(sent[0]?.effort).toBe('low')
|
|
1340
|
+
})
|
|
1341
|
+
|
|
1342
|
+
test('Jev key input preserves other env lines and chmods the file 600', async ($, on) => {
|
|
1343
|
+
let readingForSave = false
|
|
1344
|
+
const b = boundaries(on, { judge: 'jev' }, { envFile: () => readingForSave ? 'OTHER=keep\nTYPESAFE_API_KEY=old\nTAIL=yes\n' : '' })
|
|
1345
|
+
http(on)
|
|
1346
|
+
const path = '/home/test/.config/jev/.env'
|
|
1347
|
+
await start($)
|
|
1348
|
+
await command($, 'settings')
|
|
1349
|
+
const ui = await band($)
|
|
1350
|
+
readingForSave = true
|
|
1351
|
+
await ui.input({ key: 'jev-key', text: 'new-$&-$$-key' })
|
|
1352
|
+
expect(await ui.find({ key: 'jev-key' })).toBeUndefined()
|
|
1353
|
+
expect(await ui.find({ key: 'jev-test' })).toBeUndefined()
|
|
1354
|
+
expect(b.files.get(path)).toBe('OTHER=keep\nTYPESAFE_API_KEY=new-$&-$$-key\nTAIL=yes\n')
|
|
1355
|
+
expect(b.processes.at(-1)?.argv).toEqual(['chmod', '600', path])
|
|
1356
|
+
})
|
|
1357
|
+
|
|
1358
|
+
test('Jev Test displays latency and a missing-key result', async ($, on) => {
|
|
1359
|
+
boundaries(on, { judge: 'jev' })
|
|
1360
|
+
http(on)
|
|
1361
|
+
judgeStub(on, '{"effort":"low","why":"fallback"}')
|
|
1362
|
+
await start($)
|
|
1363
|
+
await command($, 'settings')
|
|
1364
|
+
const ui = await band($)
|
|
1365
|
+
await ui.press({ key: 'jev-test' })
|
|
1366
|
+
expect(await ui.find({ type: 'Text', text: /0 ms · low · Jev key missing/ })).toBeDefined()
|
|
1367
|
+
await ui.unmount()
|
|
1368
|
+
})
|
|
1369
|
+
|
|
1370
|
+
test('known agents show waiting after an eight-second tool and finish with cost', async ($, on) => {
|
|
1371
|
+
const b = boundaries(on)
|
|
1372
|
+
http(on)
|
|
1373
|
+
judgeStub(on)
|
|
1374
|
+
spawnStub(on)
|
|
1375
|
+
stepStub(on)
|
|
1376
|
+
on('tool.call', async () => { await b.clock.sleep(30000); return { result: 'ok' } })
|
|
1377
|
+
await start($)
|
|
1378
|
+
await $.agent.spawn(SPAWN)
|
|
1379
|
+
await step($, 'deepseek-v4.1-flash@low', 'low', 'worker-1')
|
|
1380
|
+
const input: ToolCallInput = { tool: 'Bash', command: 'npm test', agentId: 'worker-1', tool_use_id: 'tool-long' }
|
|
1381
|
+
const tool = $.tool.call(input)
|
|
1382
|
+
await b.clock.settle()
|
|
1383
|
+
await b.clock.advance(15000)
|
|
1384
|
+
const ui = await $.ui.mount({ plugin: 'ripple', component: 'Pane', requestId: 'ripple-agents', surface: 'terminal', props: { title: 'Agents', isFocused: true, bodyColumns: 100, placement: 'inline', scroll: { offset: 0, bodyRows: 30 }, view: {} } })
|
|
1385
|
+
expect(await ui.find({ type: 'Text', text: /waiting/ })).toBeDefined()
|
|
1386
|
+
await b.clock.advance(15000)
|
|
1387
|
+
await tool
|
|
1388
|
+
await complete($, 'worker-1')
|
|
1389
|
+
await ui.press({ key: 'done' })
|
|
1390
|
+
expect(await ui.find({ type: 'Text', text: 'Rename variable' })).toBeDefined()
|
|
1391
|
+
expect(await ui.find({ type: 'Text', text: /≈4/ })).toBeDefined()
|
|
1392
|
+
})
|
|
1393
|
+
|
|
1394
|
+
/** The claude:// link a handoff opened, and its query. */
|
|
1395
|
+
function opened(b: { processes: { argv: readonly string[] }[] }): URLSearchParams | undefined {
|
|
1396
|
+
const link = b.processes.map(p => p.argv.at(-1) ?? '').find(arg => arg.startsWith('claude://code/new?'))
|
|
1397
|
+
return link ? new URLSearchParams(link.slice('claude://code/new?'.length)) : undefined
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// 2026-10-13: "Confirm first" cleared the chat at once (it only told the new chat to wait), and
|
|
1401
|
+
// the owner lost the chat to it. A handoff now never clears: it opens a new session in the folder.
|
|
1402
|
+
test('full handoff with Ask first stops on a card, then opens a new session in the folder without clearing', async ($, on) => {
|
|
1403
|
+
const b = boundaries(on, { handoffSkill: 'custom-handoff', handoffAfter: 'confirm' })
|
|
1404
|
+
http(on)
|
|
1405
|
+
const commands: string[] = []
|
|
1406
|
+
on('command.run', { command: 'custom-handoff' }, () => { commands.push('skill'); return {} })
|
|
1407
|
+
on('command.run', { command: 'clear' }, () => { commands.push('clear'); return {} })
|
|
1408
|
+
await start($)
|
|
1409
|
+
await command($, 'handoff full')
|
|
1410
|
+
await b.clock.advance(15000)
|
|
1411
|
+
expect(commands).toEqual(['skill'])
|
|
1412
|
+
await complete($, undefined, 'Saved /work/HANDOFF.md; verify the pending tests.')
|
|
1413
|
+
await b.clock.advance(15000)
|
|
1414
|
+
expect(opened(b)).toBeUndefined()
|
|
1415
|
+
const ui = await band($, 'desktop')
|
|
1416
|
+
expect(await ui.find({ type: 'Text', text: 'Handoff ready' })).toBeDefined()
|
|
1417
|
+
await ui.press({ key: 'open-new' })
|
|
1418
|
+
const query = opened(b)
|
|
1419
|
+
expect(query?.get('folder')).toBe('/work')
|
|
1420
|
+
expect(query?.get('q')).toContain('First read the saved handoff files')
|
|
1421
|
+
expect(query?.get('q')).toContain('verify the pending tests')
|
|
1422
|
+
expect(commands).toEqual(['skill'])
|
|
1423
|
+
// Nothing is sent here: the continuation waits in the new session's prompt box.
|
|
1424
|
+
expect(b.submitted).toEqual([])
|
|
1425
|
+
})
|
|
1426
|
+
|
|
1427
|
+
test('quick handoff with New session now opens the new session at once, this chat kept', async ($, on) => {
|
|
1428
|
+
const b = boundaries(on, { handoffAfter: 'continue' })
|
|
1429
|
+
http(on)
|
|
1430
|
+
const commands: string[] = []
|
|
1431
|
+
on('command.run', { command: 'clear' }, () => { commands.push('clear'); return {} })
|
|
1432
|
+
on('model.fork', () => ({ value: { isAnswered: true, text: 'Resume: the band fix is half done.', usage: { input_tokens: 1, output_tokens: 1 } } }) as never)
|
|
1433
|
+
await start($)
|
|
1434
|
+
await command($, 'handoff')
|
|
1435
|
+
await b.clock.advance(15000)
|
|
1436
|
+
expect(opened(b)?.get('q')).toContain('the band fix is half done')
|
|
1437
|
+
expect(commands).toEqual([])
|
|
1438
|
+
})
|
|
1439
|
+
|
|
1440
|
+
test('failed quick fork uses an ordinary summary turn and copy falls back to prompt fill', async ($, on) => {
|
|
1441
|
+
const b = boundaries(on, { handoffAfter: 'copy' })
|
|
1442
|
+
http(on)
|
|
1443
|
+
on('model.fork', () => ({ value: { isAnswered: false, reason: 'nothing-to-fork' } }))
|
|
1444
|
+
on('ui.copy', () => ({ value: { isCopied: false, reason: 'no-clipboard' } }))
|
|
1445
|
+
await start($)
|
|
1446
|
+
await command($, 'handoff')
|
|
1447
|
+
await b.clock.advance(15000)
|
|
1448
|
+
expect(b.submitted[0]).toContain('Prepare a concise resume note')
|
|
1449
|
+
await complete($, undefined, 'The pending work is in /work.')
|
|
1450
|
+
await b.clock.advance(15000)
|
|
1451
|
+
expect(b.filled[0]).toContain('The pending work is in /work.')
|
|
1452
|
+
expect(b.toasts.some(t => t.includes('clipboard unavailable'))).toBe(true)
|
|
1453
|
+
})
|
|
1454
|
+
|
|
1455
|
+
test('a restored manual pick yields to the first app effort, but a fresh manual pick applies', async ($, on) => {
|
|
1456
|
+
const b = boundaries(on, { effortAuto: false })
|
|
1457
|
+
b.saved.set('pick', { effort: 'max', by: 'manual', why: 'old chat', at: 0 })
|
|
1458
|
+
http(on)
|
|
1459
|
+
const sent = stepStub(on)
|
|
1460
|
+
await start($)
|
|
1461
|
+
await step($, 'claude-opus-5-5', 'medium')
|
|
1462
|
+
expect(sent[0]?.effort).toBe('medium')
|
|
1463
|
+
await complete($)
|
|
1464
|
+
const ui = await band($)
|
|
1465
|
+
await ui.press({ key: 'effort' })
|
|
1466
|
+
await ui.press({ key: 'pick-high' })
|
|
1467
|
+
await step($, 'claude-opus-5-5', 'medium')
|
|
1468
|
+
expect(sent[1]?.effort).toBe('high')
|
|
1469
|
+
})
|
|
1470
|
+
|
|
1471
|
+
test('first-run setup finishes once and keeps its saved choices', async ($, on) => {
|
|
1472
|
+
const b = boundaries(on, { setupDone: false })
|
|
1473
|
+
http(on)
|
|
1474
|
+
await start($)
|
|
1475
|
+
const ui = await band($)
|
|
1476
|
+
expect(await ui.find({ type: 'Text', text: 'Welcome to ClaudeRipple' })).toBeDefined()
|
|
1477
|
+
await ui.press({ key: 'next' })
|
|
1478
|
+
await ui.press({ key: 'next' })
|
|
1479
|
+
await ui.press({ key: 'set-warm' })
|
|
1480
|
+
await ui.press({ key: 'next' })
|
|
1481
|
+
await ui.press({ key: 'finish' })
|
|
1482
|
+
expect(b.router().settings).toMatchObject({ setupDone: true, keepWarm: true })
|
|
1483
|
+
expect(b.storeWrites).not.toContain('settings')
|
|
1484
|
+
expect(b.storeWrites).not.toContain('setupDone')
|
|
1485
|
+
})
|
|
1486
|
+
|
|
1487
|
+
test('saving unrelated settings cannot revive stopped keep-warm; toggling it can', async ($, on) => {
|
|
1488
|
+
const b = boundaries(on, { keepWarm: true })
|
|
1489
|
+
http(on)
|
|
1490
|
+
stepStub(on)
|
|
1491
|
+
let forks = 0
|
|
1492
|
+
on('model.fork', () => { forks++; return { value: { isAnswered: false, reason: 'nothing-to-fork' } } })
|
|
1493
|
+
await start($)
|
|
1494
|
+
await step($)
|
|
1495
|
+
await complete($)
|
|
1496
|
+
await b.clock.advance(3375000)
|
|
1497
|
+
expect(forks).toBe(1)
|
|
1498
|
+
await command($, 'settings')
|
|
1499
|
+
const ui = await band($)
|
|
1500
|
+
await ui.press({ key: 'bias-1' })
|
|
1501
|
+
await b.clock.advance(15000)
|
|
1502
|
+
expect(forks).toBe(1)
|
|
1503
|
+
await command($, 'warm')
|
|
1504
|
+
await command($, 'warm')
|
|
1505
|
+
await b.clock.advance(15000)
|
|
1506
|
+
expect(forks).toBe(2)
|
|
1507
|
+
})
|
|
1508
|
+
|
|
1509
|
+
test('Save caps an existing high pick without another judge and records sent effort', async ($, on) => {
|
|
1510
|
+
const b = boundaries(on, {}, { limits: [{ kind: 'five_hour', percentUsed: 86, resetsAt: new Date(NOW + 60000).toISOString() }] })
|
|
1511
|
+
http(on)
|
|
1512
|
+
judgeStub(on, '{"effort":"high","why":"scope"}')
|
|
1513
|
+
const sent = stepStub(on)
|
|
1514
|
+
await start($)
|
|
1515
|
+
await b.clock.advance(1)
|
|
1516
|
+
await submit($)
|
|
1517
|
+
await step($)
|
|
1518
|
+
await command($, 'save')
|
|
1519
|
+
await step($)
|
|
1520
|
+
expect(sent.map(s => s.effort)).toEqual(['high', 'medium'])
|
|
1521
|
+
expect((await command($, 'stats')).text).toContain('medium 0 prompts / 4150 weighted')
|
|
1522
|
+
})
|
|
1523
|
+
|
|
1524
|
+
test('fork aggregate usage wins over the last step without main cost contamination', async ($, on) => {
|
|
1525
|
+
const b = boundaries(on, { handoffAfter: 'copy' })
|
|
1526
|
+
http(on)
|
|
1527
|
+
stepStub(on)
|
|
1528
|
+
on('model.fork', async () => {
|
|
1529
|
+
await step($)
|
|
1530
|
+
await step($)
|
|
1531
|
+
return { value: { isAnswered: true, text: 'Resume the verified work.', usage: { input_tokens: 200, output_tokens: 20, cache_read_input_tokens: 80000, cache_creation_input_tokens: 0 } } }
|
|
1532
|
+
})
|
|
1533
|
+
on('ui.copy', () => ({ value: { isCopied: true } }))
|
|
1534
|
+
await start($)
|
|
1535
|
+
await command($, 'handoff')
|
|
1536
|
+
await b.clock.advance(15000)
|
|
1537
|
+
const out = await command($, 'stats')
|
|
1538
|
+
expect(out.text).toContain('Auto: 0 prompts, 0 requests')
|
|
1539
|
+
expect(out.text).toContain('Warm: 1 pings, 8300 weighted tokens')
|
|
1540
|
+
})
|
|
1541
|
+
|
|
1542
|
+
test('handoff advice counts eligible prompts, not short followups', async ($, on) => {
|
|
1543
|
+
const b = boundaries(on, { effortAuto: false })
|
|
1544
|
+
b.setPercent(35)
|
|
1545
|
+
http(on)
|
|
1546
|
+
const calls = judgeStub(on, '{"handoff":"A different task starts now"}')
|
|
1547
|
+
await start($)
|
|
1548
|
+
await b.clock.advance(1)
|
|
1549
|
+
await submit($)
|
|
1550
|
+
await complete($)
|
|
1551
|
+
await submit($, 'go')
|
|
1552
|
+
await complete($)
|
|
1553
|
+
await b.clock.advance(15000)
|
|
1554
|
+
expect(calls.length).toBe(0)
|
|
1555
|
+
await submit($, 'Start the separate implementation task next.')
|
|
1556
|
+
await complete($)
|
|
1557
|
+
await b.clock.advance(15000)
|
|
1558
|
+
expect(calls.length).toBe(1)
|
|
1559
|
+
const ui = await band($)
|
|
1560
|
+
expect(await ui.find({ key: 'handoff' })).toBeDefined()
|
|
1561
|
+
})
|
|
1562
|
+
|
|
1563
|
+
test('quick handoff runs on the timer and copies without clearing', async ($, on) => {
|
|
1564
|
+
const b = boundaries(on, { handoffAfter: 'copy' })
|
|
1565
|
+
http(on)
|
|
1566
|
+
let copied = ''
|
|
1567
|
+
let forks = 0
|
|
1568
|
+
on('model.fork', () => { forks++; return { value: { isAnswered: true, text: 'Pending: verify the changed files.', usage: USAGE } } })
|
|
1569
|
+
on('ui.copy', ($, e) => { copied = e.text; return { value: { isCopied: true } } })
|
|
1570
|
+
await start($)
|
|
1571
|
+
await $.command.run({ command: 'ripple', origin: { kind: 'composer' }, presentation: { isFullscreen: true, columns: 140 }, args: 'handoff' })
|
|
1572
|
+
expect(forks).toBe(0)
|
|
1573
|
+
await b.clock.advance(15000)
|
|
1574
|
+
expect(forks).toBe(1)
|
|
1575
|
+
expect(copied).toContain('Pending: verify the changed files.')
|
|
1576
|
+
expect(copied).toContain('Continue with the justified next action')
|
|
1577
|
+
})
|
|
1578
|
+
|
|
1579
|
+
// ---- Regressions of 2026-10-10 (setup step 2, the band) ----------------------------------------
|
|
1580
|
+
|
|
1581
|
+
test('setup step 2 opens the dashboard; its draft preserves edits and follows remote worker settings', async ($, on) => {
|
|
1582
|
+
const b = boundaries(on, { setupDone: false })
|
|
1583
|
+
http(on)
|
|
1584
|
+
await start($)
|
|
1585
|
+
const ui = await band($)
|
|
1586
|
+
await ui.press({ key: 'judge-model' })
|
|
1587
|
+
expect(b.settingsRequests.filter(r => r.method === 'PUT').length).toBe(0)
|
|
1588
|
+
await ui.press({ key: 'next' })
|
|
1589
|
+
expect(await ui.find({ type: 'Text', text: 'Workers start on the ladder; set it up on the dashboard.' })).toBeDefined()
|
|
1590
|
+
await ui.press({ key: 'all-settings' })
|
|
1591
|
+
expect(b.processes.at(-1)?.argv).toEqual(['xdg-open', 'http://127.0.0.1:9992/#mod'])
|
|
1592
|
+
// Changes on the dashboard reach setup without replacing the person's judge edit.
|
|
1593
|
+
const general = DEFAULTS.rules.general
|
|
1594
|
+
const light = { target: { agent: 'general-purpose', model: 'haiku' }, effort: 'low' }
|
|
1595
|
+
b.remote({ settings: normalizeSettings({ setupDone: false, rules: { general: { ...general, rungs: { ...(general.mode === 'ladder' ? general.rungs : {}), light } } }, keepWarmHours: 8 }), revision: 2, stored: true })
|
|
1596
|
+
await b.clock.advance(3000)
|
|
1597
|
+
await ui.press({ key: 'next' })
|
|
1598
|
+
await ui.press({ key: 'set-warm' })
|
|
1599
|
+
await ui.press({ key: 'next' })
|
|
1600
|
+
expect(await ui.find({ type: 'Text', text: 'Claude Haiku · gpt-6-1-sol · gpt-6-astra · Claude Opus' })).toBeDefined()
|
|
1601
|
+
await ui.press({ key: 'finish' })
|
|
1602
|
+
expect(b.router().settings).toMatchObject({ judge: 'model', keepWarm: true, keepWarmHours: 8, setupDone: true })
|
|
1603
|
+
const saved = b.router().settings.rules.general
|
|
1604
|
+
expect(saved.mode === 'ladder' && saved.rungs.light).toEqual([light])
|
|
1605
|
+
expect(b.settingsRequests.filter(r => r.method === 'PUT').map(r => r.revision)).toEqual([2])
|
|
1606
|
+
await ui.unmount()
|
|
1607
|
+
})
|
|
1608
|
+
|
|
1609
|
+
test('a poll that finds nothing new writes no state, and the band draws the same pictures', async ($, on) => {
|
|
1610
|
+
const b = boundaries(on)
|
|
1611
|
+
http(on)
|
|
1612
|
+
const writes: string[] = []
|
|
1613
|
+
let counting = false
|
|
1614
|
+
on('state.set', { plugin: 'ripple' }, async ($, e, next) => { if (counting) writes.push(e.key); return next(e) })
|
|
1615
|
+
await start($)
|
|
1616
|
+
const ui = await band($, 'desktop')
|
|
1617
|
+
await b.clock.advance(3000)
|
|
1618
|
+
const before = JSON.stringify(await ui.drawn())
|
|
1619
|
+
counting = true
|
|
1620
|
+
await b.clock.advance(3000)
|
|
1621
|
+
await b.clock.advance(3000)
|
|
1622
|
+
expect(writes).toEqual([])
|
|
1623
|
+
expect(JSON.stringify(await ui.drawn())).toBe(before)
|
|
1624
|
+
await ui.unmount()
|
|
1625
|
+
})
|
|
1626
|
+
|
|
1627
|
+
test('an idle band is asked again only when the minute it shows changes', async ($, on) => {
|
|
1628
|
+
// Each ask is a new answer the desktop draws afresh; idle, only the countdown may cause one.
|
|
1629
|
+
const b = boundaries(on)
|
|
1630
|
+
http(on)
|
|
1631
|
+
judgeStub(on)
|
|
1632
|
+
stepStub(on)
|
|
1633
|
+
const writes: string[] = []
|
|
1634
|
+
const invalidates: number[] = []
|
|
1635
|
+
let counting = false
|
|
1636
|
+
on('state.set', { plugin: 'ripple' }, async ($, e, next) => { if (counting) writes.push(e.key); return next(e) })
|
|
1637
|
+
on('ui.invalidate', ($, e, next) => { if (counting) invalidates.push(b.clock.now()); return next(e) })
|
|
1638
|
+
await start($)
|
|
1639
|
+
await submit($)
|
|
1640
|
+
await step($)
|
|
1641
|
+
await complete($)
|
|
1642
|
+
const replied = b.clock.now()
|
|
1643
|
+
const ui = await band($, 'desktop')
|
|
1644
|
+
await b.clock.advance(20_000)
|
|
1645
|
+
const before = JSON.stringify(await ui.drawn())
|
|
1646
|
+
expect(before).toMatch(/"\d+m"/)
|
|
1647
|
+
counting = true
|
|
1648
|
+
// Three minutes of polls (3 s) and ticks (15 s).
|
|
1649
|
+
for (let i = 0; i < 12; i++) await b.clock.advance(15_000)
|
|
1650
|
+
expect(writes).toEqual([])
|
|
1651
|
+
expect(invalidates).toEqual([replied + 60_000, replied + 120_000, replied + 180_000])
|
|
1652
|
+
const after = JSON.stringify(await ui.drawn())
|
|
1653
|
+
expect(after).not.toBe(before)
|
|
1654
|
+
// Only the countdown moved: its minutes (value and tip) and the clock of its share.
|
|
1655
|
+
const countdownless = (tree: string) => tree.replace(/"handle":\d+/g, '').replace(/\b\d+m\b/g, 'm').replace(/[●◕◑◔○]/g, 'o').replace(/"source":"(?:[^"\\]|\\.)*","alt":"Cache"/g, '"alt":"Cache"')
|
|
1656
|
+
expect(countdownless(after)).toBe(countdownless(before))
|
|
1657
|
+
await ui.unmount()
|
|
1658
|
+
})
|
|
1659
|
+
|
|
1660
|
+
test('the judge says a failure once per reason, and Jev without a TypeSafe key asks Zen', async ($, on) => {
|
|
1661
|
+
const b = boundaries(on, { judge: 'jev' }, { config: { ...CONFIG, providers: { ...CONFIG.providers, 'opencode-zen': { headers: { authorization: 'zen-key' }, models: [{ id: 'jev-1.13' }] } } } })
|
|
1662
|
+
const sent: string[] = []
|
|
1663
|
+
http(on, false, { ...CONFIG, providers: { ...CONFIG.providers, 'opencode-zen': { headers: { authorization: 'zen-key' }, models: [{ id: 'jev-1.13' }] } } }, e => { sent.push(e.init?.headers?.authorization ?? '') })
|
|
1664
|
+
judgeStub(on)
|
|
1665
|
+
stepStub(on)
|
|
1666
|
+
await start($)
|
|
1667
|
+
await submit($)
|
|
1668
|
+
expect(sent).toEqual(['zen-key'])
|
|
1669
|
+
expect(b.toasts.filter(t => t.startsWith('Judge'))).toEqual([])
|
|
1670
|
+
})
|
|
1671
|
+
|
|
1672
|
+
test('a judge failing for the same reason is said once in a session', async ($, on) => {
|
|
1673
|
+
const b = boundaries(on, { judge: 'jev' })
|
|
1674
|
+
http(on)
|
|
1675
|
+
judgeStub(on)
|
|
1676
|
+
stepStub(on)
|
|
1677
|
+
await start($)
|
|
1678
|
+
await submit($)
|
|
1679
|
+
await complete($)
|
|
1680
|
+
await submit($, 'Implement the next bounded change and verify the tests again.')
|
|
1681
|
+
expect(b.toasts.filter(text => text.includes('keeping the current effort or asking Haiku')).length, JSON.stringify(b.toasts)).toBe(1)
|
|
1682
|
+
})
|
|
1683
|
+
|
|
1684
|
+
// 2026-10-11: on the desktop a toggle was told only by dimness, which the host's hover undoes, so a
|
|
1685
|
+
// click showed nothing. Each control must change what is drawn next, and a toggle's state must be in
|
|
1686
|
+
// the drawing itself (the picture's own `on` / `off` on the desktop, ● / ○ on the terminal).
|
|
1687
|
+
test('every band control changes the next drawing, and a toggle says its state outright', async ($, on) => {
|
|
1688
|
+
const b = boundaries(on)
|
|
1689
|
+
on('http.fetch', { url: /\/api\/requests/ }, () => ({ value: { ok: true, status: 200, headers: {}, text: JSON.stringify({ requests: [{ at: '2026-10-10T13:19:10', source: 'gpt-6-1-sol@high', target: 'gpt-6.1-sol', provider: 'chatgpt', status: 200, ok: true, ms: 2140, session: 'session-test' }] }) } }))
|
|
1690
|
+
http(on)
|
|
1691
|
+
await start($)
|
|
1692
|
+
await b.clock.advance(3000)
|
|
1693
|
+
// The terminal says it in the label (● / ○); the desktop in the control's drawing: a switch's lit
|
|
1694
|
+
// track, a lit icon, each picture marked `on` or `off` at its root.
|
|
1695
|
+
const desktopState = (n: unknown) => {
|
|
1696
|
+
const drawing = JSON.stringify(n ?? '')
|
|
1697
|
+
return /<svg[^>]* class=\\"on\\"/.test(drawing) ? 'on' : /<svg[^>]* class=\\"off\\"/.test(drawing) ? 'off' : 'other'
|
|
1698
|
+
}
|
|
1699
|
+
const state = async (ui: Awaited<ReturnType<typeof band>>, key: string, surface: string) =>
|
|
1700
|
+
surface === 'desktop' ? desktopState(await ui.find({ type: 'Box', key: 'hv-' + key })) : (n => /●/.test(String(n?.props.label)) ? 'on' : /○/.test(String(n?.props.label)) ? 'off' : 'other')(await ui.find({ type: 'Button', key }))
|
|
1701
|
+
for (const surface of ['terminal', 'desktop'] as const) {
|
|
1702
|
+
const ui = await band($, surface)
|
|
1703
|
+
const drawn = async () => JSON.stringify(await ui.drawn()).replace(/"handle":\d+/g, '')
|
|
1704
|
+
for (const key of surface === 'terminal' ? ['auto', 'warm', 'log', 'agents'] : ['auto', 'warm', 'agents']) {
|
|
1705
|
+
const before = await state(ui, key, surface)
|
|
1706
|
+
expect(before, surface + ' ' + key).not.toBe('other')
|
|
1707
|
+
const tree = await drawn()
|
|
1708
|
+
await ui.press({ key })
|
|
1709
|
+
expect(await drawn(), surface + ' ' + key).not.toBe(tree)
|
|
1710
|
+
expect(await state(ui, key, surface), surface + ' ' + key).toBe(before === 'on' ? 'off' : 'on')
|
|
1711
|
+
await ui.press({ key })
|
|
1712
|
+
expect(await state(ui, key, surface), surface + ' ' + key + ' again').toBe(before)
|
|
1713
|
+
}
|
|
1714
|
+
// The effort opens its row of levels (the terminal's `e: Effort`, the desktop's level itself).
|
|
1715
|
+
expect(await ui.find({ key: 'pick-low' })).toBeUndefined()
|
|
1716
|
+
await ui.press({ key: 'effort' })
|
|
1717
|
+
expect(await ui.find({ type: 'Button', key: 'pick-low' }), surface).toBeDefined()
|
|
1718
|
+
await ui.press({ key: 'effort' })
|
|
1719
|
+
expect(await ui.find({ key: 'pick-low' })).toBeUndefined()
|
|
1720
|
+
if (surface === 'desktop') {
|
|
1721
|
+
// The route opens the log, shown as on at once.
|
|
1722
|
+
expect(await ui.find({ type: 'Text', text: 'gpt-6.1-sol' })).toBeDefined()
|
|
1723
|
+
await ui.press({ key: 'route' })
|
|
1724
|
+
expect(b.panes.has('ripple-log')).toBe(true)
|
|
1725
|
+
await ui.press({ key: 'route' })
|
|
1726
|
+
expect(b.panes.has('ripple-log')).toBe(false)
|
|
1727
|
+
}
|
|
1728
|
+
await ui.unmount()
|
|
1729
|
+
}
|
|
1730
|
+
})
|