@sayansr26/agent-os 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +23 -0
- package/CHANGELOG.md +39 -1
- package/README.md +32 -0
- package/package.json +2 -2
- package/plugins/agent-os/.claude-plugin/icon.png +0 -0
- package/plugins/agent-os/.claude-plugin/plugin.json +2 -0
- package/plugins/auto-handoff/.claude-plugin/plugin.json +49 -0
- package/plugins/auto-handoff/LICENSE +21 -0
- package/plugins/auto-handoff/README.md +131 -0
- package/plugins/auto-handoff/UPSTREAM.md +22 -0
- package/plugins/auto-handoff/hooks/brief.ts +190 -0
- package/plugins/auto-handoff/hooks/config.ts +64 -0
- package/plugins/auto-handoff/hooks/hooks.json +1 -0
- package/plugins/auto-handoff/hooks/panel.tsx +45 -0
- package/plugins/auto-handoff/hooks/register.tsx +615 -0
- package/plugins/auto-handoff/hooks/server.ts +32 -0
- package/plugins/auto-handoff/hooks/templates.ts +17 -0
- package/plugins/auto-handoff/hooks/viewer.ts +173 -0
- package/plugins/auto-handoff/statusline/handoff-link.sh +15 -0
- package/plugins/auto-handoff/templates/brief.md +34 -0
- package/plugins/auto-handoff/templates/instructions.md +10 -0
- package/plugins/auto-handoff/tests/auto-handoff.test.tsx +931 -0
- package/plugins/auto-handoff/tests/seed-row.test.tsx +58 -0
- package/plugins/auto-handoff/tests/viewer.test.ts +111 -0
- package/plugins/auto-handoff/tsconfig.json +4 -0
- package/plugins/auto-route/.claude-plugin/plugin.json +18 -0
- package/plugins/auto-route/hooks/hooks.json +1 -0
- package/plugins/auto-route/hooks/register.ts +143 -0
- package/plugins/auto-route/tests/route.test.ts +87 -0
- package/plugins/auto-route/tsconfig.json +3 -0
- package/plugins/auto-route/types/index.d.ts +12 -0
- package/src/cli.mjs +25 -15
- package/src/plugin.mjs +39 -23
- package/src/selftest.mjs +9 -0
|
@@ -0,0 +1,931 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from 'claude-code/testing'
|
|
2
|
+
import type { TestBody } from 'claude-code/testing'
|
|
3
|
+
import type { On, SessionMessage } from 'claude-code'
|
|
4
|
+
import { briefPrompt, extractFacts, factsBlock, hasUnansweredLastRequest, isValidBrief, markUnverifiedFigures } from '../hooks/brief.ts'
|
|
5
|
+
import { renderTemplate } from '../hooks/templates.ts'
|
|
6
|
+
import { parseBrief } from '../hooks/viewer.ts'
|
|
7
|
+
|
|
8
|
+
type Calls = { compacts: number; steps: number; cleared: number; seeded: string[]; written: Record<string, string>; completes: number; tokens: number; prompts: string[]; toasts: string[]; ran: number }
|
|
9
|
+
|
|
10
|
+
// The test runs sandboxed, with no file access, so these stand in for the files in templates/:
|
|
11
|
+
// the same headings and switches, shorter prose.
|
|
12
|
+
const SHIPPED_BRIEF = `Write a handoff brief with these sections.
|
|
13
|
+
|
|
14
|
+
## Work in Progress
|
|
15
|
+
What was being worked on.
|
|
16
|
+
|
|
17
|
+
## Questions Answered
|
|
18
|
+
Things established.
|
|
19
|
+
|
|
20
|
+
## Last Request from the User
|
|
21
|
+
Copy "Last Real User Message" verbatim. Then "Status: Answered / Partially answered / Not answered".
|
|
22
|
+
|
|
23
|
+
## Next Step
|
|
24
|
+
The next action.
|
|
25
|
+
`
|
|
26
|
+
const SHIPPED_INSTRUCTIONS = `## Instructions
|
|
27
|
+
{{#priority}}
|
|
28
|
+
**PRIORITY: The "Last Request from the User" section below is not yet answered. Answer that request as your first action.**
|
|
29
|
+
{{/priority}}
|
|
30
|
+
|
|
31
|
+
This turn was triggered by the system, not by a user.
|
|
32
|
+
{{#priority}}Do the PRIORITY request and nothing else.{{/priority}}
|
|
33
|
+
{{^priority}}If the brief includes in-progress or pending work, continue that work immediately.{{/priority}}
|
|
34
|
+
|
|
35
|
+
**You MUST produce a text response before ending your turn.**
|
|
36
|
+
`
|
|
37
|
+
|
|
38
|
+
const BASIC: SessionMessage[] = [
|
|
39
|
+
{ role: 'user', text: 'please refactor the parser', toolUses: [] },
|
|
40
|
+
{ role: 'assistant', text: 'on it', toolUses: [{ tool_use_id: 'r1', tool: 'Read', input: { file_path: 'src/parser.ts' } }] },
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
const msg = (role: 'user' | 'assistant', text: string, toolUses: SessionMessage['toolUses'] = []): SessionMessage => ({ role, text, toolUses })
|
|
44
|
+
const bash = (command: string, text?: string, isError?: true) => ({ tool_use_id: 'b', tool: 'Bash', input: { command }, ...(text === undefined ? {} : { text }), ...(isError ? { isError } : {}) })
|
|
45
|
+
const edit = (file_path: string, tool = 'Edit') => ({ tool_use_id: 'e', tool, input: { file_path } })
|
|
46
|
+
|
|
47
|
+
const TURN = { answer: 'ok', durationMs: 10, isAborted: false, turnId: 't1', reason: 'answer' } as const
|
|
48
|
+
const USAGE = { input_tokens: 1, output_tokens: 1, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }
|
|
49
|
+
// The test runs under bun, which has timers; the hooks lib (es2023, no DOM) does not declare them.
|
|
50
|
+
declare const setTimeout: (fn: (...args: never[]) => void, ms: number) => unknown
|
|
51
|
+
|
|
52
|
+
// On Windows the engine hands fs hooks "/home/test/x" as "D:\home\test\x";
|
|
53
|
+
// the fake file store keys on the POSIX spelling either way.
|
|
54
|
+
const posix = (p: string) => p.replace(/^[A-Za-z]:/, '').replace(/\\/g, '/')
|
|
55
|
+
// The mod's log lands in the fake file store too (sh append or fs.write); assertions about the files a handoff writes skip it.
|
|
56
|
+
const files = (written: Record<string, string>) => Object.keys(written).filter(p => !p.endsWith('/auto-handoff.log'))
|
|
57
|
+
|
|
58
|
+
// The engine beneath the plugin: everything the mod calls, answered from memory.
|
|
59
|
+
function engine(on: On, opts: { tokens: number; files?: Record<string, string>; env?: Record<string, string>; brief?: string | null; messages?: SessionMessage[]; toolChars?: number; streamToolChars?: number; store?: Record<string, unknown>; stepUsage?: boolean; surfaces?: ('terminal' | 'desktop' | 'mobile' | 'vscode')[]; noSh?: boolean }): Calls {
|
|
60
|
+
const calls: Calls = { compacts: 0, steps: 0, cleared: 0, seeded: [], written: {}, completes: 0, tokens: opts.tokens, prompts: [], toasts: [], ran: 0 }
|
|
61
|
+
let sessionId = 'old-session'
|
|
62
|
+
let clears = 0
|
|
63
|
+
mock.env(on, { HOME: '/home/test', ...(opts.env ?? {}) })
|
|
64
|
+
clock = mock.clock(on) // the panel's spinner and collapse timers
|
|
65
|
+
on('session.surfaces', async () => ({ value: opts.surfaces ?? ['terminal'] }))
|
|
66
|
+
on('ui.render', { component: 'AbovePrompt' }, async ($, e) => { // the engine draws nothing in the band
|
|
67
|
+
const { Box } = $.ui.resolve(e)
|
|
68
|
+
return <Box />
|
|
69
|
+
})
|
|
70
|
+
mock.store(on, opts.store) // the test's $ has no store noun: what the mod finds in its store is seeded here
|
|
71
|
+
on('turn.complete', async () => ({ text: 'ok' }))
|
|
72
|
+
on('session.usage', async () => ({ value: { startedAt: 0, context: { tokens: calls.tokens, window: 200_000 }, rateLimits: [] } }))
|
|
73
|
+
on('session.id', async () => ({ value: sessionId }))
|
|
74
|
+
on('session.cwd', async () => ({ value: '/home/test/proj' }))
|
|
75
|
+
on('fs.read', async (_$, e) => {
|
|
76
|
+
const path = posix(e.path)
|
|
77
|
+
const text = calls.written[path] ?? opts.files?.[path]
|
|
78
|
+
// The shipped defaults: the real files in the mod's templates/ folder.
|
|
79
|
+
if (text === undefined && path.endsWith("/templates/brief.md")) return { value: SHIPPED_BRIEF }
|
|
80
|
+
if (text === undefined && path.endsWith("/templates/instructions.md")) return { value: SHIPPED_INSTRUCTIONS }
|
|
81
|
+
if (text === undefined) throw new Error(`ENOENT ${e.path}`)
|
|
82
|
+
return { value: text }
|
|
83
|
+
})
|
|
84
|
+
on('session.messages', async () => ({ value: opts.messages ?? BASIC }))
|
|
85
|
+
on('model.complete', async (_$, e) => {
|
|
86
|
+
calls.completes++
|
|
87
|
+
calls.prompts.push(e.prompt)
|
|
88
|
+
return {
|
|
89
|
+
value: opts.brief === null
|
|
90
|
+
? { isAnswered: false, reason: 'empty-reply', usage: USAGE }
|
|
91
|
+
: { isAnswered: true, text: opts.brief ?? '## Next Step\nFinish the parser refactor.', usage: USAGE },
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
on('fs.write', async (_$, e) => {
|
|
95
|
+
calls.written[posix(e.path)] = e.text
|
|
96
|
+
return { value: undefined }
|
|
97
|
+
})
|
|
98
|
+
on('fs.list', async (_$, e) => {
|
|
99
|
+
const dir = posix(e.path)
|
|
100
|
+
return {
|
|
101
|
+
value: Object.keys(calls.written).filter(p => p.startsWith(`${dir}/`) && !p.slice(dir.length + 1).includes('/'))
|
|
102
|
+
.map(p => ({ name: p.slice(dir.length + 1), kind: 'file' as const, size: 0, mtimeMs: 0, isLink: false })),
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
on('process.run', async (_$, e) => {
|
|
106
|
+
// No `sh`, as on Windows: every sh call refused. The log's `sh` append is kept in the file store.
|
|
107
|
+
if (opts.noSh && e.argv[0] === 'sh') throw new Error('ENOENT sh')
|
|
108
|
+
if (e.argv[0] === 'sh' && e.argv[2]?.includes('>> "$2"')) {
|
|
109
|
+
const path = posix(e.argv[5] ?? '')
|
|
110
|
+
calls.written[path] = `${calls.written[path] ?? ''}${e.argv[4]}\n`
|
|
111
|
+
}
|
|
112
|
+
return { value: { exitCode: 0, stdout: '', stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
|
|
113
|
+
})
|
|
114
|
+
on('ui.toast', async (_$, e) => {
|
|
115
|
+
calls.toasts.push(e.text)
|
|
116
|
+
return { value: undefined }
|
|
117
|
+
})
|
|
118
|
+
on('command.run', async () => {
|
|
119
|
+
calls.cleared++
|
|
120
|
+
sessionId = `new-session-${++clears}`
|
|
121
|
+
return {}
|
|
122
|
+
})
|
|
123
|
+
on('prompt.submit', async (_$, e) => {
|
|
124
|
+
calls.seeded.push(e.text)
|
|
125
|
+
return { text: e.text }
|
|
126
|
+
})
|
|
127
|
+
on('classic.SessionStart', async () => ({}))
|
|
128
|
+
on('session.compact', async (_$, e) => {
|
|
129
|
+
calls.compacts++
|
|
130
|
+
return { messages: e.messages }
|
|
131
|
+
})
|
|
132
|
+
// The real request: reached only when the mod lets the step through.
|
|
133
|
+
on('turn.step', async function* (_$, e) {
|
|
134
|
+
calls.steps++
|
|
135
|
+
// stepUsage: the response reports the request's size, as the real engine does.
|
|
136
|
+
const usage = opts.streamToolChars ? { input_tokens: 0, output_tokens: 100, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 } as never
|
|
137
|
+
: opts.stepUsage ? { input_tokens: 10, output_tokens: 0, cache_read_input_tokens: calls.tokens - 10, cache_creation_input_tokens: 0 } as never
|
|
138
|
+
: null
|
|
139
|
+
if (usage) yield { kind: 'text', index: 0, text: 'reading' }
|
|
140
|
+
yield { kind: 'stop', stopReason: 'tool_use', usage }
|
|
141
|
+
return { turnId: e.turnId, index: e.index, answer: '', toolUses: [], stopReason: 'tool_use', usage }
|
|
142
|
+
})
|
|
143
|
+
on('tool.call', async (_$, e) => (calls.ran++, { result: { content: '' }, text: 'x'.repeat(('file_path' in e && e.file_path === '/streamed.txt' ? opts.streamToolChars : opts.toolChars) ?? 0) }))
|
|
144
|
+
return calls
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const STEP = { turnId: 't1', index: 1, model: 'claude-fable-5-1', messageCount: 10 }
|
|
148
|
+
|
|
149
|
+
// Runs one model request through the chain and returns the chunks that came out.
|
|
150
|
+
async function step($: TestBody extends (...a: infer A) => unknown ? A[0] : never, index = 1) {
|
|
151
|
+
const chunks: { kind: string; text?: string }[] = []
|
|
152
|
+
for await (const c of $.turn.step({ ...STEP, index })) chunks.push(c)
|
|
153
|
+
return chunks
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let clock: ReturnType<typeof mock.clock>
|
|
157
|
+
|
|
158
|
+
const mountBand = ($: Parameters<TestBody>[0]) =>
|
|
159
|
+
$.ui.mount({ plugin: 'auto-handoff', surface: 'terminal', component: 'AbovePrompt', props: { hasSurvey: false, isWorking: false, maxRows: 20, bodyColumns: 120 } as never })
|
|
160
|
+
|
|
161
|
+
// Every string in a drawn tree, in order: Text children, a Markdown's text, a Button's label.
|
|
162
|
+
const textOf = (n: unknown): string => typeof n === 'string' ? n
|
|
163
|
+
: Array.isArray(n) ? n.map(textOf).join('')
|
|
164
|
+
: n && typeof n === 'object' ? Object.entries(n).map(([k, v]) => k === 'children' || k === 'text' || k === 'label' || k === 'props' ? textOf(v) : '').join('')
|
|
165
|
+
: ''
|
|
166
|
+
|
|
167
|
+
// The band above the prompt as drawn, flattened to its text; '' when the mod draws nothing.
|
|
168
|
+
async function band($: Parameters<TestBody>[0]): Promise<string> {
|
|
169
|
+
const ui = await mountBand($)
|
|
170
|
+
try {
|
|
171
|
+
return textOf(await ui.drawn())
|
|
172
|
+
} finally {
|
|
173
|
+
await ui.unmount()
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function settle(check: () => boolean) {
|
|
178
|
+
for (let i = 0; i < 50 && !check(); i++) await new Promise(r => setTimeout(r, 10))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
describe('auto-handoff', () => {
|
|
182
|
+
test('below the threshold nothing happens', async ($, on) => {
|
|
183
|
+
const calls = engine(on, { tokens: 100_000 })
|
|
184
|
+
await $.turn.complete(TURN)
|
|
185
|
+
await settle(() => false)
|
|
186
|
+
expect(calls.completes).toBe(0)
|
|
187
|
+
expect(calls.cleared).toBe(0)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('at the threshold: brief to disk, /clear, then seed the new session', async ($, on) => {
|
|
191
|
+
const calls = engine(on, { tokens: 165_000 })
|
|
192
|
+
await $.turn.complete(TURN)
|
|
193
|
+
await settle(() => calls.cleared > 0)
|
|
194
|
+
expect(calls.completes).toBe(1)
|
|
195
|
+
expect(files(calls.written)).toEqual(['/home/test/.claude/state/auto-handoff/old-session.md', '/home/test/.claude/state/auto-handoff/pages/old-session.html'])
|
|
196
|
+
expect(calls.written['/home/test/.claude/state/auto-handoff/old-session.md']).toContain('Finish the parser refactor')
|
|
197
|
+
// The log is how a failed handoff gets diagnosed: it must name the threshold and the brief.
|
|
198
|
+
const log = calls.written['/home/test/.claude/state/auto-handoff/auto-handoff.log'] ?? ''
|
|
199
|
+
expect(log).toContain('threshold session=old-session tokens=165000')
|
|
200
|
+
expect(log).toContain('brief written /home/test/.claude/state/auto-handoff/old-session.md')
|
|
201
|
+
expect(calls.cleared).toBe(1)
|
|
202
|
+
|
|
203
|
+
await $.classic.SessionStart({ source: 'clear' })
|
|
204
|
+
await settle(() => calls.seeded.length > 0)
|
|
205
|
+
expect(calls.seeded.length).toBe(1)
|
|
206
|
+
// The seed is one line pointing at the brief on disk, not the brief itself.
|
|
207
|
+
expect(calls.seeded[0]).toContain('Read the brief at /home/test/.claude/state/auto-handoff/old-session.md')
|
|
208
|
+
expect(calls.seeded[0]).not.toContain('Finish the parser refactor')
|
|
209
|
+
expect(calls.seeded[0]?.split('\n').length).toBe(1)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test('fires once per session', async ($, on) => {
|
|
213
|
+
const calls = engine(on, { tokens: 165_000 })
|
|
214
|
+
await $.turn.complete(TURN)
|
|
215
|
+
await settle(() => calls.cleared > 0)
|
|
216
|
+
await $.turn.complete(TURN)
|
|
217
|
+
await settle(() => false)
|
|
218
|
+
expect(calls.completes).toBe(1)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test('AUTO_HANDOFF_TOKENS lowers the threshold', async ($, on) => {
|
|
222
|
+
const calls = engine(on, { tokens: 25_000, env: { AUTO_HANDOFF_TOKENS: '20000' } })
|
|
223
|
+
await $.turn.complete(TURN)
|
|
224
|
+
await settle(() => calls.cleared > 0)
|
|
225
|
+
expect(calls.cleared).toBe(1)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
for (const name of ['AUTO_HANDOFF_DISABLE', 'DISABLE_AUTO_COMPACT']) {
|
|
229
|
+
test(`kill switch: ${name} set means no brief and no clear`, async ($, on) => {
|
|
230
|
+
const calls = engine(on, { tokens: 190_000, env: { [name]: '1' } })
|
|
231
|
+
await $.turn.complete(TURN)
|
|
232
|
+
await settle(() => false)
|
|
233
|
+
expect(calls.completes).toBe(0)
|
|
234
|
+
expect(calls.cleared).toBe(0)
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
test('subagent turns are ignored', async ($, on) => {
|
|
239
|
+
const calls = engine(on, { tokens: 190_000 })
|
|
240
|
+
await $.turn.complete({ ...TURN, agentId: 'sub-1' })
|
|
241
|
+
await settle(() => false)
|
|
242
|
+
expect(calls.completes).toBe(0)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test('a failed Haiku brief falls back to the facts and still hands off', async ($, on) => {
|
|
246
|
+
const calls = engine(on, { tokens: 190_000, brief: null, messages: [msg('assistant', 'done', [edit('/repo/src/a.ts')])] })
|
|
247
|
+
await $.turn.complete(TURN)
|
|
248
|
+
await settle(() => calls.cleared > 0)
|
|
249
|
+
const brief = calls.written['/home/test/.claude/state/auto-handoff/old-session.md']
|
|
250
|
+
expect(calls.cleared).toBe(1)
|
|
251
|
+
expect(brief).toContain('did not return a usable brief')
|
|
252
|
+
expect(brief).toContain('- /repo/src/a.ts')
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
test('a Haiku reply with no brief sections is replaced by the facts', async ($, on) => {
|
|
256
|
+
const calls = engine(on, { tokens: 190_000, brief: 'Sure, I can help with that parser refactor!' })
|
|
257
|
+
await $.turn.complete(TURN)
|
|
258
|
+
await settle(() => calls.cleared > 0)
|
|
259
|
+
const brief = calls.written['/home/test/.claude/state/auto-handoff/old-session.md']
|
|
260
|
+
expect(brief).not.toContain('Sure, I can help')
|
|
261
|
+
expect(brief).toContain('## Last Real User Message (verbatim)\nplease refactor the parser')
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('without sh the server cannot launch, and the link is the local file', async ($, on) => {
|
|
265
|
+
const calls = engine(on, { tokens: 165_000, noSh: true })
|
|
266
|
+
await $.turn.complete(TURN)
|
|
267
|
+
await settle(() => calls.cleared > 0)
|
|
268
|
+
expect(calls.written['/home/test/.claude/state/auto-handoff/pages/old-session.html']).toBeDefined()
|
|
269
|
+
await $.classic.SessionStart({ source: 'clear' })
|
|
270
|
+
await settle(() => calls.seeded.length > 0)
|
|
271
|
+
expect(calls.seeded[0]).toContain('file:///home/test/.claude/state/auto-handoff/pages/old-session.html')
|
|
272
|
+
expect(calls.seeded[0]).not.toContain('http://')
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
test('a blank viewer setting means no server, and the link is the local file', { options: { viewer: '' } }, async ($, on) => {
|
|
276
|
+
const calls = engine(on, { tokens: 165_000 })
|
|
277
|
+
await $.turn.complete(TURN)
|
|
278
|
+
await settle(() => calls.cleared > 0)
|
|
279
|
+
await $.classic.SessionStart({ source: 'clear' })
|
|
280
|
+
await settle(() => calls.seeded.length > 0)
|
|
281
|
+
expect(calls.seeded[0]).toContain('file:///home/test/.claude/state/auto-handoff/pages/old-session.html')
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
// sayan-plugins: installed by `agent-os init`, so no server unless asked for (see UPSTREAM.md).
|
|
285
|
+
test('with no viewer setting there is no server, and the link is the local file', async ($, on) => {
|
|
286
|
+
const calls = engine(on, { tokens: 165_000 })
|
|
287
|
+
await $.turn.complete(TURN)
|
|
288
|
+
await settle(() => calls.cleared > 0)
|
|
289
|
+
await $.classic.SessionStart({ source: 'clear' })
|
|
290
|
+
await settle(() => calls.seeded.length > 0)
|
|
291
|
+
expect(calls.seeded[0]).toContain('file:///home/test/.claude/state/auto-handoff/pages/old-session.html')
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
test('the threshold userConfig field sets the threshold', { options: { threshold: 100_000 } }, async ($, on) => {
|
|
295
|
+
const calls = engine(on, { tokens: 120_000 })
|
|
296
|
+
await $.turn.complete(TURN)
|
|
297
|
+
await settle(() => calls.cleared > 0)
|
|
298
|
+
expect(calls.cleared).toBe(1)
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
test('AUTO_HANDOFF_TOKENS overrides the threshold field', { options: { threshold: 100_000 } }, async ($, on) => {
|
|
302
|
+
const calls = engine(on, { tokens: 120_000, env: { AUTO_HANDOFF_TOKENS: '150000' } })
|
|
303
|
+
await $.turn.complete(TURN)
|
|
304
|
+
await settle(() => false)
|
|
305
|
+
expect(calls.cleared).toBe(0)
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
test('the brief opens with the handoff instructions', async ($, on) => {
|
|
309
|
+
const calls = engine(on, { tokens: 165_000 })
|
|
310
|
+
await $.turn.complete(TURN)
|
|
311
|
+
await settle(() => calls.cleared > 0)
|
|
312
|
+
const brief = parseBrief(calls.written['/home/test/.claude/state/auto-handoff/old-session.md'] ?? '').body
|
|
313
|
+
expect(brief.startsWith('## Instructions')).toBe(true)
|
|
314
|
+
expect(brief).toContain('triggered by the system, not by a user')
|
|
315
|
+
expect(brief).toContain('continue that work immediately')
|
|
316
|
+
expect(brief).toContain('You MUST produce a text response')
|
|
317
|
+
expect(brief).not.toContain('PRIORITY')
|
|
318
|
+
await $.classic.SessionStart({ source: 'clear' })
|
|
319
|
+
await settle(() => calls.seeded.length > 0)
|
|
320
|
+
expect(calls.seeded[0]).toContain('follow its Instructions section')
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
test('each brief gets a viewer page, and the chain links forward once the next session hands off', { options: { viewer: 'tailscale:3846' } }, async ($, on) => {
|
|
324
|
+
const calls = engine(on, { tokens: 165_000 })
|
|
325
|
+
const dir = '/home/test/.claude/state/auto-handoff'
|
|
326
|
+
await $.turn.complete(TURN)
|
|
327
|
+
await settle(() => calls.cleared > 0)
|
|
328
|
+
const first = parseBrief(calls.written[`${dir}/old-session.md`] ?? '').header
|
|
329
|
+
expect(first.chain).toBe('old-session')
|
|
330
|
+
expect(first.tokens).toBe('165000')
|
|
331
|
+
expect(first.depth).toBe('1')
|
|
332
|
+
expect(calls.prompts[0]).toContain('**Handoff depth:** 1')
|
|
333
|
+
expect(calls.written[`${dir}/pages/old-session.html`]).toContain('Session Context')
|
|
334
|
+
await $.classic.SessionStart({ source: 'clear' })
|
|
335
|
+
await settle(() => calls.seeded.length > 0)
|
|
336
|
+
// No Tailscale in the test, so the server falls back to localhost.
|
|
337
|
+
expect(calls.seeded[0]).toContain('(readable copy: http://127.0.0.1:3846/old-sess)')
|
|
338
|
+
expect(parseBrief(calls.written[`${dir}/old-session.md`] ?? '').header.to).toBe('new-session-1')
|
|
339
|
+
// The seeded session hands off in turn: same chain, and its brief points back.
|
|
340
|
+
// The seeded session's first turn sets its floor; the next one past the line hands off.
|
|
341
|
+
calls.tokens = 47_000
|
|
342
|
+
await $.turn.complete(TURN)
|
|
343
|
+
calls.tokens = 260_000
|
|
344
|
+
await $.turn.complete(TURN)
|
|
345
|
+
await settle(() => calls.cleared > 1)
|
|
346
|
+
const second = parseBrief(calls.written[`${dir}/new-session-1.md`] ?? '').header
|
|
347
|
+
expect(second.chain).toBe('old-session')
|
|
348
|
+
expect(second.from).toBe('old-session')
|
|
349
|
+
expect(second.depth).toBe('2')
|
|
350
|
+
expect(calls.prompts[1]).toContain('**Handoff depth:** 2')
|
|
351
|
+
expect(calls.written[`${dir}/pages/new-session-1.html`]).toContain('href="old-session.html"')
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
test('a session seeded before a hot reload keeps its chain: lineage comes from the store', async ($, on) => {
|
|
355
|
+
// Seen live: the reload wiped the in-memory lineage, and the next brief started a new chain.
|
|
356
|
+
const dir = '/home/test/.claude/state/auto-handoff'
|
|
357
|
+
const calls = engine(on, { tokens: 165_000, store: { 'lineage:old-session': { from: 'earlier', chain: 'first' } } })
|
|
358
|
+
await $.turn.complete(TURN)
|
|
359
|
+
await settle(() => calls.cleared > 0)
|
|
360
|
+
const header = parseBrief(calls.written[`${dir}/old-session.md`] ?? '').header
|
|
361
|
+
expect(header.from).toBe('earlier')
|
|
362
|
+
expect(header.chain).toBe('first')
|
|
363
|
+
// Seeded before depth was recorded: unknown beats a wrong guess.
|
|
364
|
+
expect(header.depth).toBeUndefined()
|
|
365
|
+
expect(calls.prompts[0]).not.toContain('Handoff depth')
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
test('depth carries through the store across a hot reload', async ($, on) => {
|
|
369
|
+
const dir = '/home/test/.claude/state/auto-handoff'
|
|
370
|
+
const calls = engine(on, { tokens: 165_000, store: { 'lineage:old-session': { from: 'earlier', chain: 'first', depth: 3 } } })
|
|
371
|
+
await $.turn.complete(TURN)
|
|
372
|
+
await settle(() => calls.cleared > 0)
|
|
373
|
+
expect(parseBrief(calls.written[`${dir}/old-session.md`] ?? '').header.depth).toBe('3')
|
|
374
|
+
expect(calls.prompts[0]).toContain('**Handoff depth:** 3')
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
test('an unanswered last request puts a PRIORITY directive in the instructions', async ($, on) => {
|
|
378
|
+
const brief = '## Last Request from the User\n"Make the thresholds vars"\nStatus: Partially answered.\n\n## Next Step\nAdd userConfig.'
|
|
379
|
+
const calls = engine(on, { tokens: 165_000, brief })
|
|
380
|
+
await $.turn.complete(TURN)
|
|
381
|
+
await settle(() => calls.cleared > 0)
|
|
382
|
+
const written = calls.written['/home/test/.claude/state/auto-handoff/old-session.md'] ?? ''
|
|
383
|
+
expect(written).toContain('**PRIORITY:')
|
|
384
|
+
expect(written).toContain('Do the PRIORITY request and nothing else.')
|
|
385
|
+
expect(written.indexOf('**PRIORITY:')).toBeLessThan(written.indexOf('## Session Handoff Brief'))
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
test('the brief template file sets the sections Haiku is asked for', async ($, on) => {
|
|
389
|
+
const files = { '/home/test/.claude/auto-handoff/brief.md': 'Write only:\n\n## Mood\nHow it went.' }
|
|
390
|
+
const calls = engine(on, { tokens: 165_000, files, brief: '## Mood\nGood.' })
|
|
391
|
+
await $.turn.complete(TURN)
|
|
392
|
+
await settle(() => calls.cleared > 0)
|
|
393
|
+
expect(calls.prompts[0]).toContain('## Mood\nHow it went.')
|
|
394
|
+
expect(calls.prompts[0]).not.toContain('## Dead Ends')
|
|
395
|
+
// Valid against the custom template, so Haiku's text is kept.
|
|
396
|
+
expect(calls.written['/home/test/.claude/state/auto-handoff/old-session.md']).toContain('## Mood\nGood.')
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
test('the instructions template file replaces the instructions', { options: { instructionsTemplate: '/custom/i.md' } }, async ($, on) => {
|
|
400
|
+
const calls = engine(on, { tokens: 165_000, files: { '/custom/i.md': '## Rules\nJust keep going.' } })
|
|
401
|
+
await $.turn.complete(TURN)
|
|
402
|
+
await settle(() => calls.cleared > 0)
|
|
403
|
+
const brief = parseBrief(calls.written['/home/test/.claude/state/auto-handoff/old-session.md'] ?? '').body
|
|
404
|
+
expect(brief.startsWith('## Rules\nJust keep going.')).toBe(true)
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
test('without sh, the log is written through fs and keeps every line', async ($, on) => {
|
|
408
|
+
const calls = engine(on, { tokens: 165_000, noSh: true })
|
|
409
|
+
await $.turn.complete(TURN)
|
|
410
|
+
await settle(() => calls.cleared > 0)
|
|
411
|
+
const log = calls.written['/home/test/.claude/state/auto-handoff/auto-handoff.log'] ?? ''
|
|
412
|
+
expect(log).toContain('threshold session=old-session tokens=165000')
|
|
413
|
+
expect(log).toContain('brief written /home/test/.claude/state/auto-handoff/old-session.md')
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
test('a new session writes missing templates and leaves existing ones alone', async ($, on) => {
|
|
417
|
+
const calls = engine(on, { tokens: 1_000, files: { '/home/test/.claude/auto-handoff/brief.md': 'mine' } })
|
|
418
|
+
await $.classic.SessionStart({ source: 'startup' })
|
|
419
|
+
expect(files(calls.written)).toEqual(['/home/test/.claude/auto-handoff/instructions.md'])
|
|
420
|
+
expect(calls.written['/home/test/.claude/auto-handoff/instructions.md']).toContain('{{#priority}}')
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
test('renderTemplate keeps the branch that matches the flag', async () => {
|
|
424
|
+
const t = 'A\n{{#priority}}P{{/priority}}{{^priority}}N{{/priority}}\nB'
|
|
425
|
+
expect(renderTemplate(t, { priority: true })).toBe('A\nP\nB')
|
|
426
|
+
expect(renderTemplate(t, { priority: false })).toBe('A\nN\nB')
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
test('hasUnansweredLastRequest reads only the last-request section', async () => {
|
|
430
|
+
expect(hasUnansweredLastRequest('## Last Request from the User\n"x"\nStatus: Not answered')).toBe(true)
|
|
431
|
+
expect(hasUnansweredLastRequest('## Last Request from the User\n"x"\nStatus: Answered')).toBe(false)
|
|
432
|
+
expect(hasUnansweredLastRequest('## Last Request from the User\nStatus: Answered\n\n## Open Questions\nStatus: Not answered')).toBe(false)
|
|
433
|
+
expect(hasUnansweredLastRequest('## Last Request from the User\nNo user message found. Status: Not answered')).toBe(false)
|
|
434
|
+
expect(hasUnansweredLastRequest('## Next Step\nStatus: Not answered')).toBe(false)
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
test('the brief carries the transcript path, verify block and facts', async ($, on) => {
|
|
438
|
+
const messages = [
|
|
439
|
+
msg('user', 'fix #846 please'),
|
|
440
|
+
msg('assistant', 'committing', [edit('/repo/mod.ts', 'Write'), bash("git commit -m 'feat: x'", '[main 47edd8e] feat(mods): auto-handoff\n 2 files changed')]),
|
|
441
|
+
]
|
|
442
|
+
const calls = engine(on, { tokens: 190_000, messages })
|
|
443
|
+
await $.turn.complete(TURN)
|
|
444
|
+
await settle(() => calls.cleared > 0)
|
|
445
|
+
const brief = calls.written['/home/test/.claude/state/auto-handoff/old-session.md']
|
|
446
|
+
expect(brief).toContain('`~/.claude/projects/-home-test-proj/old-session.jsonl`')
|
|
447
|
+
expect(brief).toContain('## How to Use This Brief')
|
|
448
|
+
expect(brief).toContain('Finish the parser refactor')
|
|
449
|
+
expect(brief).toContain('- feat(mods): auto-handoff (47edd8e)')
|
|
450
|
+
expect(brief).not.toContain('## Last Real User Message') // Haiku's section quotes it; no duplicate
|
|
451
|
+
const prompt = calls.prompts[0] ?? ''
|
|
452
|
+
expect(prompt).toContain('- /repo/mod.ts')
|
|
453
|
+
expect(prompt).toContain('## Questions Answered')
|
|
454
|
+
expect(prompt.indexOf('## Extracted Facts')).toBeLessThan(prompt.indexOf('## Next Step'))
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
test('a /clear from the user without a pending handoff seeds nothing', async ($, on) => {
|
|
458
|
+
const calls = engine(on, { tokens: 1_000 })
|
|
459
|
+
await $.classic.SessionStart({ source: 'clear' })
|
|
460
|
+
expect(calls.seeded.length).toBe(0)
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
test('a seeded session that starts above the threshold does not loop', async ($, on) => {
|
|
464
|
+
// The 2026-10-03 live run: threshold 20k, every fresh session started at ~31k.
|
|
465
|
+
const calls = engine(on, { tokens: 31_000, env: { AUTO_HANDOFF_TOKENS: '20000' } })
|
|
466
|
+
await $.turn.complete(TURN)
|
|
467
|
+
await settle(() => calls.cleared > 0)
|
|
468
|
+
await $.classic.SessionStart({ source: 'clear' })
|
|
469
|
+
await settle(() => calls.seeded.length > 0)
|
|
470
|
+
await $.turn.complete(TURN) // the seed turn: sets the floor
|
|
471
|
+
await $.turn.complete(TURN) // still ~31k: below floor + growth (a quarter of the threshold)
|
|
472
|
+
await settle(() => false)
|
|
473
|
+
expect(calls.cleared).toBe(1)
|
|
474
|
+
|
|
475
|
+
calls.tokens = 90_000 // grew 59k past the floor
|
|
476
|
+
await $.turn.complete(TURN)
|
|
477
|
+
await settle(() => calls.cleared > 1)
|
|
478
|
+
expect(calls.cleared).toBe(2)
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
test('a seeded session measures its floor at its first request, not at the end of its first turn', async ($, on) => {
|
|
482
|
+
// Seen live: the seed turn read the whole mod and ran the suite three times, 47k to 129k,
|
|
483
|
+
// with the pre-request check off; the floor landed at 129k and the next handoff moved out to 179k.
|
|
484
|
+
const calls = engine(on, { tokens: 165_000, stepUsage: true, toolChars: 560_000 })
|
|
485
|
+
await $.turn.complete(TURN)
|
|
486
|
+
await settle(() => calls.cleared > 0)
|
|
487
|
+
await $.classic.SessionStart({ source: 'clear' })
|
|
488
|
+
await settle(() => calls.seeded.length > 0)
|
|
489
|
+
|
|
490
|
+
calls.tokens = 47_000 // the fresh session, brief and CLAUDE.md loaded
|
|
491
|
+
await step($, 0) // the seed turn's first request: this is the floor
|
|
492
|
+
expect(calls.steps).toBe(1)
|
|
493
|
+
await $.tool.call({ tool: 'Read', file_path: '/big.txt' }) // ~140k of reads land in the same turn
|
|
494
|
+
const chunks = await step($, 1)
|
|
495
|
+
expect(calls.steps).toBe(1) // stopped before the request that would carry 187k
|
|
496
|
+
expect(chunks.some(c => c.kind === 'text' && c.text?.includes('auto-handoff'))).toBe(true)
|
|
497
|
+
await settle(() => calls.cleared > 1)
|
|
498
|
+
expect(calls.cleared).toBe(2)
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
// Hand off once per round with no typed prompt between rounds; growth clears the floor each time.
|
|
502
|
+
async function unattendedRounds($: Parameters<TestBody>[0], calls: Calls, rounds: number) {
|
|
503
|
+
for (let i = 0; i < rounds; i++) {
|
|
504
|
+
const before = calls.cleared
|
|
505
|
+
await $.turn.complete(TURN)
|
|
506
|
+
await settle(() => calls.cleared > before)
|
|
507
|
+
if (calls.cleared === before) return
|
|
508
|
+
await $.classic.SessionStart({ source: 'clear' })
|
|
509
|
+
await settle(() => calls.seeded.length >= calls.cleared)
|
|
510
|
+
await $.turn.complete(TURN) // seed turn sets the floor
|
|
511
|
+
calls.tokens += 60_000
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
test('progress guard: no more than two handoffs without a typed prompt', async ($, on) => {
|
|
516
|
+
const calls = engine(on, { tokens: 31_000, env: { AUTO_HANDOFF_TOKENS: '20000' } })
|
|
517
|
+
await unattendedRounds($, calls, 4)
|
|
518
|
+
await settle(() => false)
|
|
519
|
+
expect(calls.cleared).toBe(2)
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
test('maxConsecutiveHandoffs sets the progress guard', { options: { maxConsecutiveHandoffs: 1 } }, async ($, on) => {
|
|
523
|
+
const calls = engine(on, { tokens: 31_000, env: { AUTO_HANDOFF_TOKENS: '20000' } })
|
|
524
|
+
await unattendedRounds($, calls, 3)
|
|
525
|
+
await settle(() => false)
|
|
526
|
+
expect(calls.cleared).toBe(1)
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
test('a seeded session gets 40k of headroom above its floor, not a config field', { options: { headroom: 1_000_000, growth: 1_000_000 } }, async ($, on) => {
|
|
530
|
+
const calls = engine(on, { tokens: 120_000, env: { AUTO_HANDOFF_TOKENS: '100000' } })
|
|
531
|
+
await $.turn.complete(TURN)
|
|
532
|
+
await settle(() => calls.cleared > 0)
|
|
533
|
+
await $.classic.SessionStart({ source: 'clear' })
|
|
534
|
+
await settle(() => calls.seeded.length > 0)
|
|
535
|
+
calls.tokens = 90_000 // floor 90k: the line moves to 130k
|
|
536
|
+
await $.turn.complete(TURN) // seed turn sets the floor
|
|
537
|
+
calls.tokens = 125_000
|
|
538
|
+
await $.turn.complete(TURN)
|
|
539
|
+
await settle(() => false)
|
|
540
|
+
expect(calls.cleared).toBe(1) // 125k: past the 100k threshold, short of floor + 40k
|
|
541
|
+
calls.tokens = 131_000
|
|
542
|
+
await $.turn.complete(TURN)
|
|
543
|
+
await settle(() => calls.cleared > 1)
|
|
544
|
+
expect(calls.cleared).toBe(2) // the ignored options did not hold it off
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
test('a threshold too close to the floor is raised, and the panel names the env var', async ($, on) => {
|
|
548
|
+
// The 2026-10-04 live run: AUTO_HANDOFF_TOKENS=80000 left in a shell, seeded sessions start at
|
|
549
|
+
// ~45k. max(80k, 45k + 20k) was 80k, so each seeded session handed off after ~35k of work: eight times.
|
|
550
|
+
const calls = engine(on, { tokens: 85_000, env: { AUTO_HANDOFF_TOKENS: '80000' } })
|
|
551
|
+
await $.turn.complete(TURN)
|
|
552
|
+
await settle(() => calls.cleared > 0)
|
|
553
|
+
await $.classic.SessionStart({ source: 'clear' })
|
|
554
|
+
await settle(() => calls.seeded.length > 0)
|
|
555
|
+
calls.tokens = 45_000
|
|
556
|
+
await $.turn.complete(TURN) // seed turn sets the floor: 35k of headroom, under the 40k minimum
|
|
557
|
+
const warning = "threshold 80k (AUTO_HANDOFF_TOKENS) leaves 35k this session's 45k start: handing off at 85k instead"
|
|
558
|
+
expect(await band($)).toContain(`⚠ ${warning}`)
|
|
559
|
+
calls.tokens = 81_000 // past 80k; the old math handed off here
|
|
560
|
+
await $.turn.complete(TURN)
|
|
561
|
+
await settle(() => false)
|
|
562
|
+
expect(calls.cleared).toBe(1)
|
|
563
|
+
expect((await band($)).split(warning).length - 1).toBe(1) // warned once, not every turn
|
|
564
|
+
calls.tokens = 86_000 // floor + 40k
|
|
565
|
+
await $.turn.complete(TURN)
|
|
566
|
+
await settle(() => calls.cleared > 1)
|
|
567
|
+
expect(calls.cleared).toBe(2)
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
test('the headroom warning names /config when the threshold field is the source', { options: { threshold: 60_000 } }, async ($, on) => {
|
|
571
|
+
const calls = engine(on, { tokens: 65_000 })
|
|
572
|
+
await $.turn.complete(TURN)
|
|
573
|
+
await settle(() => calls.cleared > 0)
|
|
574
|
+
await $.classic.SessionStart({ source: 'clear' })
|
|
575
|
+
await settle(() => calls.seeded.length > 0)
|
|
576
|
+
calls.tokens = 45_000
|
|
577
|
+
await $.turn.complete(TURN)
|
|
578
|
+
expect(await band($)).toContain('(threshold in /config)')
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
test('no headroom warning at the default threshold', async ($, on) => {
|
|
582
|
+
const calls = engine(on, { tokens: 165_000 })
|
|
583
|
+
await $.turn.complete(TURN)
|
|
584
|
+
await settle(() => calls.cleared > 0)
|
|
585
|
+
await $.classic.SessionStart({ source: 'clear' })
|
|
586
|
+
await settle(() => calls.seeded.length > 0)
|
|
587
|
+
calls.tokens = 45_000
|
|
588
|
+
await $.turn.complete(TURN)
|
|
589
|
+
expect(await band($)).not.toContain('threshold')
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
test('progress guard: a typed prompt resumes handoffs', async ($, on) => {
|
|
593
|
+
const calls = engine(on, { tokens: 31_000, env: { AUTO_HANDOFF_TOKENS: '20000' } })
|
|
594
|
+
await unattendedRounds($, calls, 3)
|
|
595
|
+
expect(calls.cleared).toBe(2)
|
|
596
|
+
await $.prompt.submit({ text: 'keep going', origin: { kind: 'composer' } } as never)
|
|
597
|
+
await $.turn.complete(TURN)
|
|
598
|
+
await settle(() => calls.cleared > 2)
|
|
599
|
+
expect(calls.cleared).toBe(3)
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
test('progress guard: a plugin or notification prompt is not progress', async ($, on) => {
|
|
603
|
+
const calls = engine(on, { tokens: 31_000, env: { AUTO_HANDOFF_TOKENS: '20000' } })
|
|
604
|
+
await unattendedRounds($, calls, 3)
|
|
605
|
+
await $.prompt.submit({ text: 'from a plugin', origin: { kind: 'plugin', name: 'other' } } as never)
|
|
606
|
+
await $.prompt.submit({ text: 'task done', origin: { kind: 'task-notification' } } as never)
|
|
607
|
+
await $.turn.complete(TURN)
|
|
608
|
+
await settle(() => false)
|
|
609
|
+
expect(calls.cleared).toBe(2)
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
test('a turn whose tool output would cross the threshold stops before the next request', async ($, on) => {
|
|
613
|
+
// The 2026-10-03 case: measured 63k, then reads worth ~140k land in one turn.
|
|
614
|
+
const calls = engine(on, { tokens: 63_000, env: { AUTO_HANDOFF_TOKENS: '80000' }, toolChars: 560_000 })
|
|
615
|
+
await $.tool.call({ tool: 'Read', file_path: '/big.txt' })
|
|
616
|
+
const chunks = await step($)
|
|
617
|
+
expect(calls.steps).toBe(0)
|
|
618
|
+
expect(chunks.some(c => c.kind === 'text' && c.text?.includes('auto-handoff'))).toBe(true)
|
|
619
|
+
await settle(() => calls.cleared > 0)
|
|
620
|
+
expect(calls.completes).toBe(1)
|
|
621
|
+
expect(calls.cleared).toBe(1)
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
test('tool output that lands while the response streams still counts', async ($, on) => {
|
|
625
|
+
// Seen live: eight reads ran mid-stream, then the stream's end zeroed the count.
|
|
626
|
+
const calls = engine(on, { tokens: 61_000, env: { AUTO_HANDOFF_TOKENS: '80000' }, streamToolChars: 400_000 })
|
|
627
|
+
const stream = $.turn.step({ ...STEP, index: 1 })[Symbol.asyncIterator]()
|
|
628
|
+
await stream.next() // the response is still streaming
|
|
629
|
+
await $.tool.call({ tool: 'Read', file_path: '/streamed.txt' }) // core runs the tool now
|
|
630
|
+
while (!(await stream.next()).done) {}
|
|
631
|
+
expect(calls.steps).toBe(1)
|
|
632
|
+
const chunks = await step($, 2)
|
|
633
|
+
expect(calls.steps).toBe(1)
|
|
634
|
+
expect(chunks.some(c => c.kind === 'text' && c.text?.includes('auto-handoff'))).toBe(true)
|
|
635
|
+
})
|
|
636
|
+
|
|
637
|
+
test('auto-compact is replaced by a handoff', async ($, on) => {
|
|
638
|
+
const calls = engine(on, { tokens: 174_000, env: { AUTO_HANDOFF_TOKENS: '80000' } })
|
|
639
|
+
const r = await $.session.compact({ trigger: 'auto', messages: BASIC })
|
|
640
|
+
expect(r.skip).toContain('auto-handoff')
|
|
641
|
+
expect(calls.compacts).toBe(0)
|
|
642
|
+
await settle(() => calls.cleared > 0)
|
|
643
|
+
expect(calls.cleared).toBe(1)
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
test('a manual /compact is left alone', async ($, on) => {
|
|
647
|
+
const calls = engine(on, { tokens: 174_000, env: { AUTO_HANDOFF_TOKENS: '80000' } })
|
|
648
|
+
await $.session.compact({ trigger: 'manual', messages: BASIC })
|
|
649
|
+
expect(calls.compacts).toBe(1)
|
|
650
|
+
expect(calls.completes).toBe(0)
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
test('auto-compact goes ahead when the kill switch is set', async ($, on) => {
|
|
654
|
+
const calls = engine(on, { tokens: 174_000, env: { AUTO_HANDOFF_TOKENS: '80000', AUTO_HANDOFF_DISABLE: 'x' } })
|
|
655
|
+
await $.session.compact({ trigger: 'auto', messages: BASIC })
|
|
656
|
+
expect(calls.compacts).toBe(1)
|
|
657
|
+
expect(calls.cleared).toBe(0)
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
test('small tool output under the threshold lets the request through', async ($, on) => {
|
|
661
|
+
const calls = engine(on, { tokens: 63_000, env: { AUTO_HANDOFF_TOKENS: '80000' }, toolChars: 20_000 })
|
|
662
|
+
await $.tool.call({ tool: 'Read', file_path: '/small.txt' })
|
|
663
|
+
await step($)
|
|
664
|
+
expect(calls.steps).toBe(1)
|
|
665
|
+
expect(calls.completes).toBe(0)
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
test('the first request of a turn is never stopped', async ($, on) => {
|
|
669
|
+
const calls = engine(on, { tokens: 63_000, env: { AUTO_HANDOFF_TOKENS: '80000' }, toolChars: 560_000 })
|
|
670
|
+
await $.tool.call({ tool: 'Read', file_path: '/big.txt' })
|
|
671
|
+
await step($, 0)
|
|
672
|
+
expect(calls.steps).toBe(1)
|
|
673
|
+
})
|
|
674
|
+
|
|
675
|
+
test('a stale briefing marker from a mid-handoff reload is retried', async ($, on) => {
|
|
676
|
+
// Seen live: threshold fired, the mod reloaded 71 ms later, the marker stayed at 'briefing'.
|
|
677
|
+
const calls = engine(on, { tokens: 165_000, store: { 'fired:old-session': 'briefing' } })
|
|
678
|
+
await $.turn.complete(TURN)
|
|
679
|
+
await settle(() => calls.cleared > 0)
|
|
680
|
+
expect(calls.completes).toBe(1)
|
|
681
|
+
expect(calls.cleared).toBe(1)
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
test('a clearing or seeded marker still means fired once', async ($, on) => {
|
|
685
|
+
const calls = engine(on, { tokens: 165_000, store: { 'fired:old-session': 'seeded:new-session-1' } })
|
|
686
|
+
await $.turn.complete(TURN)
|
|
687
|
+
await settle(() => false)
|
|
688
|
+
expect(calls.completes).toBe(0)
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
test('the panel above the prompt walks the handoff, then collapses', { options: { viewer: 'tailscale:3846' } }, async ($, on) => {
|
|
692
|
+
const calls = engine(on, { tokens: 165_000, stepUsage: true })
|
|
693
|
+
expect(await band($)).toBe('')
|
|
694
|
+
await $.turn.complete(TURN)
|
|
695
|
+
await settle(() => calls.cleared > 0)
|
|
696
|
+
const clearing = await band($)
|
|
697
|
+
expect(clearing).toContain('auto-handoff · 165k / 160k')
|
|
698
|
+
expect(clearing).toContain('✓ brief written')
|
|
699
|
+
expect(clearing).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] clearing/)
|
|
700
|
+
await clock.advance(100)
|
|
701
|
+
expect(await band($)).not.toBe(clearing) // the spinner moved
|
|
702
|
+
|
|
703
|
+
await $.classic.SessionStart({ source: 'clear' })
|
|
704
|
+
await settle(() => calls.seeded.length > 0)
|
|
705
|
+
expect(await band($)).toContain('✓ cleared')
|
|
706
|
+
expect(await band($)).toContain('starting the fresh session')
|
|
707
|
+
|
|
708
|
+
calls.tokens = 47_000
|
|
709
|
+
await step($, 0) // the seed turn's first request
|
|
710
|
+
const done = await band($)
|
|
711
|
+
expect(done).toContain('✓ handed off · 165k → 47k')
|
|
712
|
+
expect(done).toContain('[open brief](http://127.0.0.1:3846/old-sess)')
|
|
713
|
+
expect(done).not.toContain('Dismiss')
|
|
714
|
+
await clock.advance(10_000)
|
|
715
|
+
expect(await band($)).toBe('')
|
|
716
|
+
expect(calls.toasts).toEqual([])
|
|
717
|
+
})
|
|
718
|
+
|
|
719
|
+
test('on the mobile app, which draws no band, the key moments go out as toasts', async ($, on) => {
|
|
720
|
+
const calls = engine(on, { tokens: 165_000, stepUsage: true, surfaces: ['terminal', 'mobile'] })
|
|
721
|
+
await $.turn.complete(TURN)
|
|
722
|
+
await settle(() => calls.cleared > 0)
|
|
723
|
+
await settle(() => calls.toasts.length > 0)
|
|
724
|
+
expect(calls.toasts).toEqual(['context 165k is past 160k: handing off'])
|
|
725
|
+
await $.classic.SessionStart({ source: 'clear' })
|
|
726
|
+
await settle(() => calls.seeded.length > 0)
|
|
727
|
+
calls.tokens = 47_000
|
|
728
|
+
await step($, 0)
|
|
729
|
+
await settle(() => calls.toasts.length > 1)
|
|
730
|
+
expect(calls.toasts).toEqual(['context 165k is past 160k: handing off', '↪ handed off · 165k → 47k'])
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
test('a facts-only brief stays on the panel until dismissed', async ($, on) => {
|
|
734
|
+
const calls = engine(on, { tokens: 165_000, stepUsage: true, brief: null })
|
|
735
|
+
await $.turn.complete(TURN)
|
|
736
|
+
await settle(() => calls.cleared > 0)
|
|
737
|
+
expect(await band($)).toContain('⚠ brief is facts only: the summary failed (empty-reply)')
|
|
738
|
+
await $.classic.SessionStart({ source: 'clear' })
|
|
739
|
+
await settle(() => calls.seeded.length > 0)
|
|
740
|
+
calls.tokens = 47_000
|
|
741
|
+
await step($, 0)
|
|
742
|
+
await clock.advance(10_000)
|
|
743
|
+
const ui = await mountBand($)
|
|
744
|
+
expect(textOf(await ui.drawn())).toContain('⚠ brief is facts only')
|
|
745
|
+
await ui.press({ key: 'dismiss' })
|
|
746
|
+
expect(textOf(await ui.drawn())).toBe('')
|
|
747
|
+
await ui.unmount()
|
|
748
|
+
})
|
|
749
|
+
|
|
750
|
+
test('past the threshold, tool calls are refused before they run', async ($, on) => {
|
|
751
|
+
// Seen live: ~28 reads in one step took the session from 67k to 437k.
|
|
752
|
+
const calls = engine(on, { tokens: 67_000, env: { AUTO_HANDOFF_TOKENS: '80000' }, toolChars: 220_000 })
|
|
753
|
+
const results = []
|
|
754
|
+
for (let i = 0; i < 28; i++) results.push(await $.tool.call({ tool: 'Read', file_path: `/export-${i}.txt` }))
|
|
755
|
+
expect(calls.ran).toBe(1) // 67k + 55k crosses 80k after the first read
|
|
756
|
+
expect(results.slice(1).every(r => 'deny' in r && r.deny?.includes('[auto-handoff] Not run'))).toBe(true)
|
|
757
|
+
expect(calls.cleared).toBe(0) // the refusal does not hand off by itself
|
|
758
|
+
|
|
759
|
+
await step($)
|
|
760
|
+
expect(calls.steps).toBe(0) // the next request is stopped, under the window, and the handoff runs
|
|
761
|
+
await settle(() => calls.cleared > 0)
|
|
762
|
+
expect(calls.cleared).toBe(1)
|
|
763
|
+
expect(calls.compacts).toBe(0)
|
|
764
|
+
})
|
|
765
|
+
|
|
766
|
+
test('a refused tool call hands off at turn end even when the real size comes in under the threshold', async ($, on) => {
|
|
767
|
+
// Seen live: the gate projected 83.7k, the response measured 72.5k, and the session stopped with no handoff.
|
|
768
|
+
const calls = engine(on, { tokens: 67_000, env: { AUTO_HANDOFF_TOKENS: '80000' }, toolChars: 220_000 })
|
|
769
|
+
await $.tool.call({ tool: 'Read', file_path: '/a.txt' })
|
|
770
|
+
const refused = await $.tool.call({ tool: 'Read', file_path: '/b.txt' })
|
|
771
|
+
expect('deny' in refused && refused.deny?.includes('[auto-handoff] Not run')).toBe(true)
|
|
772
|
+
calls.tokens = 72_500
|
|
773
|
+
await $.turn.complete(TURN)
|
|
774
|
+
await settle(() => calls.cleared > 0)
|
|
775
|
+
expect(calls.cleared).toBe(1)
|
|
776
|
+
})
|
|
777
|
+
|
|
778
|
+
test('a refused tool call hands off at the next request, and the stop line does not claim a size under the threshold', async ($, on) => {
|
|
779
|
+
const calls = engine(on, { tokens: 84_000, env: { AUTO_HANDOFF_TOKENS: '80000' } })
|
|
780
|
+
const refused = await $.tool.call({ tool: 'Read', file_path: '/a.txt' })
|
|
781
|
+
expect('deny' in refused).toBe(true)
|
|
782
|
+
calls.tokens = 72_500 // the next measurement comes in under the threshold
|
|
783
|
+
const chunks = await step($)
|
|
784
|
+
expect(calls.steps).toBe(0)
|
|
785
|
+
const text = chunks.map(c => c.text ?? '').join('')
|
|
786
|
+
expect(text).toContain('A tool call was refused at the handoff threshold (80k)')
|
|
787
|
+
expect(text).not.toContain('would carry')
|
|
788
|
+
await settle(() => calls.cleared > 0)
|
|
789
|
+
expect(calls.cleared).toBe(1)
|
|
790
|
+
})
|
|
791
|
+
|
|
792
|
+
test('without a refusal, a turn that ends under the threshold does not hand off', async ($, on) => {
|
|
793
|
+
const calls = engine(on, { tokens: 67_000, env: { AUTO_HANDOFF_TOKENS: '80000' }, toolChars: 4_000 })
|
|
794
|
+
await $.tool.call({ tool: 'Read', file_path: '/a.txt' })
|
|
795
|
+
await $.turn.complete(TURN)
|
|
796
|
+
await settle(() => false)
|
|
797
|
+
expect(calls.cleared).toBe(0)
|
|
798
|
+
})
|
|
799
|
+
|
|
800
|
+
test("the person's own /clear dismisses the panel when no handoff is under way", async ($, on) => {
|
|
801
|
+
const calls = engine(on, { tokens: 165_000, stepUsage: true, brief: null })
|
|
802
|
+
await $.turn.complete(TURN)
|
|
803
|
+
await settle(() => calls.cleared > 0)
|
|
804
|
+
await $.classic.SessionStart({ source: 'clear' })
|
|
805
|
+
await settle(() => calls.seeded.length > 0)
|
|
806
|
+
calls.tokens = 47_000
|
|
807
|
+
await step($, 0)
|
|
808
|
+
await clock.advance(10_000)
|
|
809
|
+
expect(await band($)).toContain('⚠ brief is facts only') // sticky
|
|
810
|
+
await $.classic.SessionStart({ source: 'clear' })
|
|
811
|
+
expect(await band($)).toBe('')
|
|
812
|
+
expect(calls.seeded.length).toBe(1)
|
|
813
|
+
})
|
|
814
|
+
|
|
815
|
+
test('the handoff\'s own /clear keeps the panel', async ($, on) => {
|
|
816
|
+
const calls = engine(on, { tokens: 165_000 })
|
|
817
|
+
await $.turn.complete(TURN)
|
|
818
|
+
await settle(() => calls.cleared > 0)
|
|
819
|
+
await $.classic.SessionStart({ source: 'clear' })
|
|
820
|
+
await settle(() => calls.seeded.length > 0)
|
|
821
|
+
expect(await band($)).toContain('✓ cleared')
|
|
822
|
+
})
|
|
823
|
+
|
|
824
|
+
test('the tool gate never blocks a session the mod will not hand off', async ($, on) => {
|
|
825
|
+
const calls = engine(on, { tokens: 165_000, env: { AUTO_HANDOFF_DISABLE: 'x' }, toolChars: 220_000 })
|
|
826
|
+
await $.tool.call({ tool: 'Read', file_path: '/a.txt' })
|
|
827
|
+
await $.tool.call({ tool: 'Read', file_path: '/b.txt' })
|
|
828
|
+
expect(calls.ran).toBe(2)
|
|
829
|
+
})
|
|
830
|
+
|
|
831
|
+
test('the tool gate leaves subagents alone', async ($, on) => {
|
|
832
|
+
const calls = engine(on, { tokens: 165_000, toolChars: 220_000 })
|
|
833
|
+
await $.tool.call({ tool: 'Read', file_path: '/a.txt', agentId: 'sub-1' } as never)
|
|
834
|
+
expect(calls.ran).toBe(1)
|
|
835
|
+
})
|
|
836
|
+
|
|
837
|
+
test('the pre-request check honours the kill switch', async ($, on) => {
|
|
838
|
+
const calls = engine(on, { tokens: 63_000, env: { AUTO_HANDOFF_TOKENS: '80000', AUTO_HANDOFF_DISABLE: 'x' }, toolChars: 560_000 })
|
|
839
|
+
await $.tool.call({ tool: 'Read', file_path: '/big.txt' })
|
|
840
|
+
await step($)
|
|
841
|
+
expect(calls.steps).toBe(1)
|
|
842
|
+
expect(calls.completes).toBe(0)
|
|
843
|
+
})
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
describe('extractFacts', () => {
|
|
847
|
+
test('heredoc commit messages count when git output is not in yet', async () => {
|
|
848
|
+
const f = extractFacts([msg('assistant', '', [bash("git add x && git commit -F - <<'EOF'\nfix(mods): loop guard\n\nWHY: chained six times\nEOF")])])
|
|
849
|
+
expect(f.commits).toEqual(['fix(mods): loop guard'])
|
|
850
|
+
})
|
|
851
|
+
|
|
852
|
+
test('git output wins, and a commit that did not land is dropped', async () => {
|
|
853
|
+
const f = extractFacts([msg('assistant', '', [
|
|
854
|
+
bash('git commit -m "first"', '[main abc1234] first real subject\n 1 file changed'),
|
|
855
|
+
bash('git commit -m "second"', 'nothing to commit, working tree clean'),
|
|
856
|
+
bash('git commit -m "third"', 'hook rejected', true),
|
|
857
|
+
])])
|
|
858
|
+
expect(f.commits).toEqual(['first real subject (abc1234)'])
|
|
859
|
+
})
|
|
860
|
+
|
|
861
|
+
test('files matching ignoreFiles are excluded; real edits are kept', async () => {
|
|
862
|
+
const ignore = /(?:-state\.json|\/cache\/.*\.json)$/
|
|
863
|
+
const f = extractFacts([msg('assistant', '', [
|
|
864
|
+
edit('/a/data/favorites-state.json', 'Write'),
|
|
865
|
+
edit('/a/data/cache/feed.json', 'Write'),
|
|
866
|
+
edit('/a/notes/meeting.md'),
|
|
867
|
+
edit('/a/src/brief.ts', 'Write'),
|
|
868
|
+
])], ignore)
|
|
869
|
+
expect(f.filesModified).toEqual(['/a/notes/meeting.md', '/a/src/brief.ts'])
|
|
870
|
+
})
|
|
871
|
+
|
|
872
|
+
test('harness signals are never the last real request', async () => {
|
|
873
|
+
const f = extractFacts([
|
|
874
|
+
msg('user', 'Port all five gaps into the mod'),
|
|
875
|
+
msg('user', 'Stop hook feedback: reply promises a check-back'),
|
|
876
|
+
msg('user', '[Automatic handoff] The previous session reached its context limit.'),
|
|
877
|
+
msg('user', '[auto-handoff] The previous session (x) reached its context limit'),
|
|
878
|
+
msg('user', '', []),
|
|
879
|
+
])
|
|
880
|
+
expect(f.lastUserMessage).toBe('Port all five gaps into the mod')
|
|
881
|
+
})
|
|
882
|
+
|
|
883
|
+
test('issue numbers come from the user and the assistant, not from tool noise', async () => {
|
|
884
|
+
const f = extractFacts([msg('user', 'work #846'), msg('assistant', 'see #761 and color #fff', [bash('echo "#9999"')])])
|
|
885
|
+
expect(f.issues).toEqual(['#846', '#761'])
|
|
886
|
+
})
|
|
887
|
+
|
|
888
|
+
test('brief validation needs at least one expected section', async () => {
|
|
889
|
+
const template = '## Work in Progress\nWhat was happening.\n\n## Next Step\nThe next action.'
|
|
890
|
+
expect(isValidBrief('## Next Step\nGo.', template)).toBe(true)
|
|
891
|
+
expect(isValidBrief('Sure! Here is a summary.', template)).toBe(false)
|
|
892
|
+
})
|
|
893
|
+
})
|
|
894
|
+
|
|
895
|
+
describe('handoff numbers', () => {
|
|
896
|
+
const base = { filesModified: [], commits: [], issues: [] }
|
|
897
|
+
|
|
898
|
+
test('the prompt carries the real token figures and where the threshold came from', () => {
|
|
899
|
+
const p = briefPrompt([], { ...base, handoffTokens: 93_105, threshold: 85_000, thresholdSource: 'AUTO_HANDOFF_TOKENS', seededSessionStartSize: 45_000, unattendedCount: 2 }, 'TEMPLATE')
|
|
900
|
+
expect(p).toContain('## Handoff Numbers')
|
|
901
|
+
expect(p).toContain('**Tokens at handoff:** 93105 (93k)')
|
|
902
|
+
expect(p).toContain('**Threshold:** 85000 (85k), from AUTO_HANDOFF_TOKENS')
|
|
903
|
+
expect(p).toContain('starting size (seeded from a handoff):** 45000 (45k)')
|
|
904
|
+
expect(p).toContain('no user message:** 2')
|
|
905
|
+
expect(p.indexOf('## Handoff Numbers')).toBeLessThan(p.indexOf('TEMPLATE'))
|
|
906
|
+
})
|
|
907
|
+
|
|
908
|
+
test('no numbers, no section', () => {
|
|
909
|
+
expect(factsBlock(base)).not.toContain('Handoff Numbers')
|
|
910
|
+
})
|
|
911
|
+
})
|
|
912
|
+
|
|
913
|
+
describe('markUnverifiedFigures', () => {
|
|
914
|
+
const facts = { filesModified: [], commits: [], issues: [], handoffTokens: 93_105, threshold: 85_141, thresholdSource: 'AUTO_HANDOFF_TOKENS (80k), raised to leave 40k above the starting size', seededSessionStartSize: 45_141 }
|
|
915
|
+
|
|
916
|
+
test('marks an invented figure and leaves the real ones alone', () => {
|
|
917
|
+
const r = markUnverifiedFigures('Low: session burned its 200k budget. It handed off at 93k of 85k, from 80k with 40k room over a 45,141 tokens start.', facts)
|
|
918
|
+
expect(r.flagged).toEqual(['200k'])
|
|
919
|
+
expect(r.text).toContain('200k [unverified: not in Handoff Numbers]')
|
|
920
|
+
expect(r.text).toContain('at 93k of 85k')
|
|
921
|
+
expect(r.text).not.toContain('45,141 tokens [unverified')
|
|
922
|
+
})
|
|
923
|
+
|
|
924
|
+
test('rounding to the nearest thousand passes; issue numbers and years are not figures', () => {
|
|
925
|
+
expect(markUnverifiedFigures('93.1k tokens, see #2 and PR 2026', facts).flagged).toEqual([])
|
|
926
|
+
})
|
|
927
|
+
|
|
928
|
+
test('with no numbers block, any figure is marked', () => {
|
|
929
|
+
expect(markUnverifiedFigures('at 120k', { filesModified: [], commits: [], issues: [] }).flagged).toEqual(['120k'])
|
|
930
|
+
})
|
|
931
|
+
})
|