@cmodjs/core 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/docs/commands.md +4 -4
  2. package/docs/dependencies.md +1 -1
  3. package/docs/jobs.md +1 -1
  4. package/docs/mod.md +2 -3
  5. package/docs/permissions.md +1 -1
  6. package/docs/session.md +3 -1
  7. package/docs/testing.md +2 -1
  8. package/jobs/permissions.d.ts.map +1 -1
  9. package/jobs/permissions.js +4 -1
  10. package/package.json +1 -1
  11. package/records.d.ts +1 -1
  12. package/records.js +1 -1
  13. package/register.d.ts.map +1 -1
  14. package/register.js +5 -4
  15. package/runtime/before.d.ts +8 -4
  16. package/runtime/before.d.ts.map +1 -1
  17. package/runtime/before.js +63 -39
  18. package/runtime/conversations.d.ts.map +1 -1
  19. package/runtime/conversations.js +38 -13
  20. package/runtime/engine.d.ts +1 -0
  21. package/runtime/engine.d.ts.map +1 -1
  22. package/runtime/hooks.d.ts +9 -23
  23. package/runtime/hooks.d.ts.map +1 -1
  24. package/runtime/hooks.js +4 -59
  25. package/runtime/lifecycle.d.ts +2 -1
  26. package/runtime/lifecycle.d.ts.map +1 -1
  27. package/runtime/lifecycle.js +36 -23
  28. package/runtime/permission-check.d.ts +15 -0
  29. package/runtime/permission-check.d.ts.map +1 -0
  30. package/runtime/permission-check.js +37 -0
  31. package/runtime/router.d.ts +0 -1
  32. package/runtime/router.d.ts.map +1 -1
  33. package/runtime/router.js +0 -1
  34. package/runtime/sessions.d.ts +13 -8
  35. package/runtime/sessions.d.ts.map +1 -1
  36. package/runtime/sessions.js +44 -80
  37. package/runtime/state.d.ts.map +1 -1
  38. package/runtime/state.js +14 -11
  39. package/session/context/turn/message/message.d.ts +1 -0
  40. package/session/context/turn/message/message.d.ts.map +1 -1
  41. package/session/context/turn/message/message.js +5 -2
  42. package/src/jobs/permissions.ts +3 -1
  43. package/src/records.ts +1 -1
  44. package/src/register.ts +5 -4
  45. package/src/runtime/before.ts +66 -42
  46. package/src/runtime/conversations.ts +35 -13
  47. package/src/runtime/engine.ts +1 -0
  48. package/src/runtime/hooks.ts +13 -86
  49. package/src/runtime/lifecycle.ts +51 -37
  50. package/src/runtime/permission-check.ts +47 -0
  51. package/src/runtime/router.ts +0 -2
  52. package/src/runtime/sessions.ts +55 -84
  53. package/src/runtime/state.ts +15 -8
  54. package/src/session/context/turn/message/message.ts +6 -2
  55. package/src/testing.ts +17 -10
  56. package/testing.d.ts +7 -2
  57. package/testing.d.ts.map +1 -1
  58. package/testing.js +13 -7
@@ -12,12 +12,13 @@ import { GoalChange, type Goal } from '../session/goal/goal.js'
12
12
  import { TaskEnd, TaskStart, type Task } from '../session/goal/task.js'
13
13
  import { beforeEvents, processEvents, Session, sessionEvents, type BeforeEvent, type Placement, type BeforeMethods, type Surface, type OnMethods, type ProcessBeforeMethods, type ProcessName, type ProcessOnMethods, type RecordPart, type SearchOptions, type SessionEvent, type SessionScope, type SessionSend, type Sessions, type TaskOwner, type TaskStartArgs, type ToolCallArgs, type Usage } from '../session/session.js'
14
14
  import { matches, sentMatcher, type Matcher, type SentMatcher } from '../utils/matcher.js'
15
- import { foldAnswers, type Answers, type Place } from './before.js'
15
+ import { beforeAnswerKeys, foldAnswers, foldBefore, methodOf, takenAnswer, type Answers, type Place } from './before.js'
16
16
  import type { Claude } from './claude.js'
17
17
  import { cmodPluginName } from './dependencies.js'
18
18
  import { messageOf } from '../utils/text.js'
19
19
  import { listenerOf, PassedTimeout, withinTime, type ListenOptions, type ListenTime } from './deadline.js'
20
- import { callFiles, type Hook, type HookAnswer } from './hooks.js'
20
+ import { callFiles, type HomeFolders, type HookFields } from './hooks.js'
21
+ import type { PermissionRoutes } from './permission-check.js'
21
22
 
22
23
  export type Subscription = { readonly event: SessionEvent; readonly matcher: SentMatcher | undefined }
23
24
 
@@ -50,8 +51,6 @@ const ownSchedule: readonly string[] = ['CronDelete', 'CronList']
50
51
 
51
52
  export const beforePermission = (event: BeforeEvent): string => (event === 'toolCall' ? 'tools' : event === TaskStart ? 'tasks' : 'prompt')
52
53
 
53
- const methodOf = (prefix: 'on' | 'before', event: SessionEvent): string => `${prefix}${Object.entries(sessionEvents).find(([, each]) => each === event)?.[0] ?? event}`
54
-
55
54
  export const isAct = (action: string): action is Act => action === 'callTool' || action in actPermissions
56
55
 
57
56
  export function toolPermission(tool: string): string | undefined {
@@ -134,7 +133,13 @@ export function isInScope(scope: Scope, session: string, change: SessionChange,
134
133
  return (change.type === TurnStart || change.type === TurnEnd) && change.turn.prompt.id === scope.turn
135
134
  }
136
135
 
137
- export function createSessionsClient({ name, claude, report, isGranted, hookOn, routeOn }: { readonly name: string; readonly claude: Claude; readonly report: (id: string, message: string, error: unknown) => void; readonly isGranted: (permission: string) => boolean; readonly hookOn?: (hook: string, listener: (input: unknown) => unknown) => Disposable; readonly routeOn?: RouteOn }): SessionsClient {
136
+ export function withoutPermissionCheck(name: string, hearer: string): string {
137
+ return `${name}: ${hearer} hears Claude Code's permission check, so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod.`
138
+ }
139
+
140
+ export type HookOn = (hook: string, listener: (input: unknown) => HookFields | undefined | Promise<HookFields | undefined>) => Disposable
141
+
142
+ export function createSessionsClient({ name, claude, report, isGranted, hookOn, routeOn, permissionRoutes }: { readonly name: string; readonly claude: Claude; readonly report: (id: string, message: string, error: unknown) => void; readonly isGranted: (permission: string) => boolean; readonly hookOn?: HookOn; readonly routeOn?: RouteOn; readonly permissionRoutes?: () => PermissionRoutes | undefined }): SessionsClient {
138
143
  const call = <Answer>(method: string, input: unknown) => claude.cmod.call({ to: cmodPluginName, method, input }) as Promise<Answer>
139
144
  const listening = new Map<string, Set<Listening>>()
140
145
  const deciding = new Map<string, Set<Listening>>()
@@ -197,6 +202,13 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
197
202
 
198
203
 
199
204
 
205
+ const invoke = (method: string, options: ListenOptions, run: (time: ListenTime) => unknown, takes: readonly string[] = []): Promise<unknown> =>
206
+ withinTime(claude.clock, method, options, run).catch((error: unknown) => {
207
+ if (error instanceof PassedTimeout && !method.startsWith('before')) return undefined
208
+ report(`session:${method}`, `The ${method} listener failed`, error)
209
+ return options.onFailure === 'refuse' && takes.includes('refuse') ? { refuse: `${name}: ${messageOf(error)}` } : undefined
210
+ })
211
+
200
212
  const combined = async (session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean): Promise<Partial<Answers> | undefined> => {
201
213
  const askers = [...(deciding.get(session) ?? []), ...(isInProject ? (deciding.get(everySession) ?? []) : [])]
202
214
  .filter((entry) => entry.event === event)
@@ -204,15 +216,11 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
204
216
  if (!isInScope(entry.scope, session, latest, isUnderIn(session))) return undefined
205
217
  if (entry.matcher !== undefined && !matches(payloadOf(latest), entry.matcher)) return undefined
206
218
  const payload = await payloadFor(session, latest)
207
- const answer = await withinTime(claude.clock, entry.method, entry.options, (time) => entry.listener(payload as never, time)).catch((error: unknown) => {
208
- if (entry.options.onFailure !== 'refuse') throw error
209
- report(`session:${entry.method}`, `The ${entry.method} listener failed`, error)
210
- return { refuse: `${name}: ${messageOf(error)}` }
211
- })
219
+ const answer = await invoke(entry.method, entry.options, async (time) => takenAnswer(entry.method, beforeAnswerKeys[event], await entry.listener(payload as never, time)), beforeAnswerKeys[event])
212
220
  if (answer === undefined || answer === null || (typeof answer === 'object' && 'refuse' in answer) || isGranted(beforePermission(event))) return answer
213
221
  throw new Error(`Its ${entry.method} answer changes nothing until ${beforePermission(event)} is turned on for ${name} in /mods.`)
214
222
  })
215
- return foldAnswers(event, change, askers, (error) => report(`session:${methodOf('before', event)}`, `The ${methodOf('before', event)} listener failed`, error))
223
+ return foldBefore(event, change, askers, (error) => report(`session:${methodOf('before', event)}`, `The ${methodOf('before', event)} listener failed`, error))
216
224
  }
217
225
 
218
226
  const eventMethods = (scopeOf: () => Scope, hasBefore: boolean, ended?: string): OnMethods & Partial<BeforeMethods> => {
@@ -226,9 +234,11 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
226
234
  ]) as OnMethods & Partial<BeforeMethods>
227
235
  }
228
236
 
237
+ let folders: Promise<HomeFolders> | undefined
229
238
  const withFiles = async (message: Message): Promise<Message> => {
230
239
  if (message.type !== ToolCall && message.type !== ToolResult) return message
231
- return { ...message, files: await callFiles(name, claude, { tool: message.name, input: message.input }, message.cwd) }
240
+ folders ??= Promise.all([claude.env.home(), claude.env.configHome()]).then(([home, configRoot]) => ({ home, configRoot }))
241
+ return { ...message, files: await callFiles(name, claude, folders, { tool: message.name, input: message.input }, message.cwd) }
232
242
  }
233
243
 
234
244
  const messagesFrom = async (method: string, input: unknown) => Promise.all((await call<Message[]>(method, input)).map(withFiles))
@@ -426,39 +436,17 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
426
436
  const processListeners = new Map<string, Set<ProcessListener>>()
427
437
 
428
438
  const raise = async (method: string, payload: HookFields): Promise<HookFields | undefined> => {
429
- const isBefore = method.startsWith('before')
430
439
  const heard = [...(processListeners.get(method) ?? [])].filter(({ matcher }) => payload['task'] === undefined && (matcher === undefined || matches(payload, matcher)))
431
- if (!isBefore) {
432
- await Promise.all(
433
- heard.map(({ listener, options }) =>
434
- withinTime(claude.clock, method, options, (time) => listener(payload, time)).catch((error: unknown) => {
435
- if (!(error instanceof PassedTimeout)) report(`session:${method}`, `The ${method} listener failed`, error)
436
- }),
437
- ),
438
- )
440
+ if (!method.startsWith('before')) {
441
+ await Promise.all(heard.map(({ listener, options }) => invoke(method, options, (time) => listener(payload, time))))
439
442
  return undefined
440
443
  }
444
+ const failing = method === 'beforePermissionRequest' ? { onFailure: 'refuse' as const } : {}
441
445
  const takes = answerKeys[method.slice('before'.length)] ?? []
442
- let merged: HookFields | undefined
443
- for (const { listener, options } of heard) {
444
- try {
445
- const answer = (await withinTime(claude.clock, method, options, (time) => listener(payload, time))) as HookFields | null | undefined
446
- if (answer === undefined || answer === null) continue
447
- const unread = Object.keys(answer).filter((key) => !takes.includes(key))
448
- if (unread.length > 0) throw new Error(`it answered ${unread.join(' and ')}, which ${method} does not take. It takes ${takes.map((key) => `{ ${key} }`).join(', ') || 'no answer'}.`)
449
- const notes = [merged?.['note'], answer['note']].filter((note): note is string => typeof note === 'string')
450
- merged = { ...merged, ...answer, ...(notes.length === 0 ? {} : { note: notes.join('\n\n') }) }
451
- if (typeof answer['refuse'] === 'string') return merged
452
- } catch (error) {
453
- report(`session:${method}`, `The ${method} listener failed`, error)
454
- if (options.onFailure === 'refuse' || method === 'beforePermissionRequest') return { refuse: `${name}: ${messageOf(error)}` }
455
- }
456
- }
457
- return merged
446
+ const askers = heard.map(({ listener, options }) => () => invoke(method, { ...options, ...failing }, async (time) => takenAnswer(method, takes, await listener(payload, time)), takes))
447
+ return foldAnswers<undefined, HookFields>(method, takes, ['refuse'], undefined, askers, (error) => report(`session:${method}`, `The ${method} listener failed`, error))
458
448
  }
459
449
 
460
- const permissionCheck = (e: ToolCheck, below: ToolCheckVerdict): HookFields => ({ session: current.id, cwd: current.cwd, ...defined({ task: e.agentId, callId: e.tool_use_id, reason: below.reason, rule: below.rule, ceiling: e.ceiling === undefined ? undefined : verdictName(e.ceiling) }), name: e.tool, input: recordOf(e.input), decision: verdictName(below.decision) })
461
-
462
450
  const commandRun = async (e: Args<'command.run'>): Promise<HookFields> => {
463
451
  const source = (await claude.command.list()).find((command) => command.name === e.command)?.source
464
452
  return { session: current.id, cwd: current.cwd, name: e.command, args: e.args, ...defined({ source }), from: senderOf(e.origin, false) }
@@ -483,30 +471,21 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
483
471
  const listenTo = (method: string, named: string) => {
484
472
  if (hookOn === undefined || routeOn === undefined) throw new Error(`${name}: ${method} works only in a mod's own session.`)
485
473
  const isBefore = method.startsWith('before')
486
- if (named === 'PermissionCheck') {
487
- return routeOn('tool.check', async (e: ToolCheck, next) => {
488
- const below = (await next(e)) as ToolCheckVerdict
489
- if (!isBefore) {
490
- void raise(method, permissionCheck(e, below))
491
- return below
492
- }
493
- const answer = await raise(method, permissionCheck(e, below))
494
- if (answer === undefined || below.decision === 'deny') return below
495
- if (typeof answer['refuse'] === 'string') return { decision: 'deny', reason: answer['refuse'] }
496
- if (typeof answer['ask'] === 'string') return { decision: 'ask', reason: answer['ask'] }
497
- return answer['allow'] === true ? { decision: 'allow' } : below
498
- })
474
+ if (named === 'PermissionCheck' || named === 'PermissionRequest') {
475
+ const routes = permissionRoutes?.()
476
+ if (routes === undefined) throw new Error(withoutPermissionCheck(name, method))
477
+ return routes(method, { raise, hookOn, routeOn, current: () => current })
499
478
  }
500
479
  const event: { readonly on: string; readonly before?: string } = processEvents[named as ProcessName]
501
480
  const hook = (isBefore ? event.before : undefined) ?? event.on
502
481
  return hookOn(hook, async (input: unknown) => {
503
- const payload = processPayloadOf(named as Exclude<ProcessName, 'PermissionCheck'>, input)
482
+ const payload = processPayloadOf(named as ProcessFieldsName, input)
504
483
  if (!isBefore) {
505
484
  if (named !== 'SessionStart' || payload['source'] !== 'compact') void raise(method, payload)
506
485
  return undefined
507
486
  }
508
487
  const answer = await raise(method, payload)
509
- return answer === undefined ? undefined : hookAnswerOf(named, hook, answer)
488
+ return answer === undefined ? undefined : hookAnswerOf(named, answer)
510
489
  })
511
490
  }
512
491
 
@@ -550,14 +529,14 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
550
529
  },
551
530
  changed(session, change, isInProject) {
552
531
  remember(session, change)
532
+ let payload: Promise<unknown> | undefined
553
533
  for (const key of isInProject ? [session, everySession] : [session]) {
554
534
  for (const { scope, event, method, matcher, listener, options } of listening.get(key) ?? []) {
555
535
  if (!isHeardBy(event, matcher, change) || !isInScope(scope, session, change, isUnderIn(session))) continue
556
- void payloadFor(session, change)
557
- .then((payload) => withinTime(claude.clock, method, options, (time) => listener(payload as never, time)))
558
- .catch((error: unknown) => {
559
- if (!(error instanceof PassedTimeout)) report(`session:${method}`, `The ${method} listener failed`, error)
560
- })
536
+ payload ??= payloadFor(session, change)
537
+ void payload
538
+ .then((heard) => invoke(method, options, (time) => listener(heard as never, time)))
539
+ .catch((error: unknown) => report(`session:${method}`, `The ${method} listener failed`, error))
561
540
  }
562
541
  }
563
542
  },
@@ -583,22 +562,16 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
583
562
  }
584
563
  }
585
564
 
586
- type HookFields = Readonly<Record<string, unknown>>
587
-
588
- type ToolCheck = { readonly tool: string; readonly input: unknown; readonly tool_use_id?: string; readonly agentId?: string; readonly ceiling?: 'allow' | 'ask' | 'deny' }
589
-
590
- type ToolCheckVerdict = { readonly decision: 'allow' | 'ask' | 'deny'; readonly reason?: string; readonly rule?: string }
565
+ export type ToolCheck = { readonly tool: string; readonly input: unknown; readonly tool_use_id?: string; readonly agentId?: string; readonly ceiling?: 'allow' | 'ask' | 'deny' }
591
566
 
592
567
  export type RouteOn = {
593
568
  (event: 'tool.check', hook: (e: ToolCheck, next: (e: ToolCheck) => Promise<unknown>) => Promise<unknown>): Disposable
594
569
  (event: 'command.run', hook: (e: Args<'command.run'>, next: (e: Args<'command.run'>) => Promise<CommandRunResult>) => Promise<CommandRunResult>): Disposable
595
570
  }
596
571
 
597
- const verdictName = (decision: 'allow' | 'ask' | 'deny') => (decision === 'deny' ? 'refuse' : decision)
598
-
599
- const recordOf = (value: unknown): Readonly<Record<string, unknown>> => (typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {})
572
+ export const recordOf = (value: unknown): Readonly<Record<string, unknown>> => (typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {})
600
573
 
601
- const defined = (fields: HookFields): HookFields => Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined))
574
+ export const defined = (fields: HookFields): HookFields => Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined))
602
575
 
603
576
  const placementOf = (input: HookFields): Placement => defined({ session: input['session_id'], task: input['agent_id'], agentType: input['agent_type'], cwd: input['cwd'], permissionMode: input['permission_mode'] }) as Placement
604
577
 
@@ -635,7 +608,9 @@ const answerKeys: Readonly<Record<string, readonly string[]>> = {
635
608
 
636
609
  const todoOf = (input: HookFields) => ({ id: input['task_id'], subject: input['task_subject'], description: input['task_description'], teammate: input['teammate_name'] })
637
610
 
638
- const processFields: { readonly [Name in Exclude<keyof typeof processEvents, 'PermissionCheck'>]: (input: HookFields) => HookFields } = {
611
+ type ProcessFieldsName = Exclude<ProcessName, 'PermissionCheck'>
612
+
613
+ const processFields: { readonly [Name in ProcessFieldsName]: (input: HookFields) => HookFields } = {
639
614
  ProjectSetup: (input) => ({ trigger: input['trigger'] }),
640
615
  SessionStart: (input) => ({
641
616
  source: input['source'],
@@ -683,28 +658,24 @@ export async function spawnTask(claude: Pick<Claude, 'agent'>, task: TaskStartAr
683
658
  return { id: started.agentId, title: task.title, agentType: task.agentType ?? 'general-purpose', model: started.model, openedBy: '', openedAt: new Date().toISOString() }
684
659
  }
685
660
 
686
- export function processPayloadOf(named: Exclude<keyof typeof processEvents, 'PermissionCheck'>, input: unknown): HookFields {
661
+ export function processPayloadOf(named: ProcessFieldsName, input: unknown): HookFields {
687
662
  const fields = recordOf(input)
688
663
  return { ...placementOf(fields), ...defined(processFields[named](fields)) }
689
664
  }
690
665
 
691
- export function hookAnswerOf(named: string, hook: string, answer: HookFields): HookAnswer | undefined {
692
- const output = (fields: HookFields): HookAnswer => ({ hookSpecificOutput: { hookEventName: hook as Hook, ...defined(fields) } })
666
+ function hookAnswerOf(named: string, answer: HookFields): HookFields | undefined {
693
667
  const refuse = typeof answer['refuse'] === 'string' ? answer['refuse'] : undefined
694
- if (named === 'SessionStart') return output({ additionalContext: answer['note'], sessionTitle: answer['title'], reloadSkills: answer['reloadSkills'], initialUserMessage: answer['firstPrompt'] })
668
+ const note = typeof answer['note'] === 'string' ? [answer['note']] : undefined
669
+ if (named === 'SessionStart') return defined({ additionalContext: note, sessionTitle: answer['title'], reloadSkills: answer['reloadSkills'] === true ? true : undefined, initialUserMessage: answer['firstPrompt'] })
695
670
  if (named === 'ModelChange') {
696
- if (refuse !== undefined) return output({ permissionDecision: 'deny', permissionDecisionReason: refuse })
697
- return typeof answer['ask'] === 'string' ? output({ permissionDecision: 'ask', permissionDecisionReason: answer['ask'] }) : undefined
698
- }
699
- if (named === 'WorktreeCreate') return output({ worktreePath: answer['result'] })
700
- if (named === 'PermissionRequest') {
701
- if (refuse !== undefined) return output({ decision: defined({ behavior: 'deny', message: refuse, interrupt: answer['interrupt'] }) })
702
- return answer['allow'] === true ? output({ decision: defined({ behavior: 'allow', updatedInput: answer['input'], updatedPermissions: answer['updates'] }) }) : undefined
671
+ if (refuse !== undefined) return { permissionDecision: 'deny', permissionDecisionReason: refuse }
672
+ return typeof answer['ask'] === 'string' ? { permissionDecision: 'ask', permissionDecisionReason: answer['ask'] } : undefined
703
673
  }
704
- if (named === 'PermissionDenial') return output({ retry: answer['retry'] })
705
- if (named === 'ToolBatch') return output({ additionalContext: answer['note'] })
706
- if (named === 'ReplyDisplay') return output({ displayContent: answer['text'] })
707
- return refuse === undefined ? undefined : { decision: 'block', reason: refuse }
674
+ if (named === 'WorktreeCreate') return defined({ worktreePath: answer['result'] })
675
+ if (named === 'PermissionDenial') return answer['retry'] === true ? { retry: true } : undefined
676
+ if (named === 'ToolBatch') return defined({ additionalContext: note })
677
+ if (named === 'ReplyDisplay') return defined({ displayContent: answer['text'] })
678
+ return refuse === undefined ? undefined : { block: refuse }
708
679
  }
709
680
 
710
681
  export function resultOf(where: Pick<Place, 'session' | 'context' | 'turn'>, call: ToolCallArgs & { readonly cwd: string }, answered: Awaited<ReturnType<Claude['tool']['call']>>): ToolResult {
@@ -84,14 +84,19 @@ export function createState<State extends object>({ name, initial, session, root
84
84
 
85
85
  const forgetEndedSessions = async (current: string) => {
86
86
  const stored = await claude.store.keys()
87
- for (const recorded of stored.filter((key) => key.startsWith(transcriptsKey))) {
88
- const owner = recorded.slice(transcriptsKey.length)
89
- if (owner === current) continue
87
+ const ended = async (recorded: string) => {
90
88
  const transcript = await claude.store.get(recorded)
91
- if (typeof transcript === 'string' && (await claude.fs.exists(transcript))) continue
92
- for (const value of stored.filter((key) => key.startsWith(`${name}.`) && key.endsWith(`.sessions.${owner}`))) await claude.store.delete(value)
93
- await claude.store.delete(recorded)
89
+ return typeof transcript !== 'string' || !(await claude.fs.exists(transcript))
94
90
  }
91
+ const owners = stored.filter((key) => key.startsWith(transcriptsKey) && key !== `${transcriptsKey}${current}`)
92
+ await Promise.all(
93
+ owners.map(async (recorded) => {
94
+ if (!(await ended(recorded))) return
95
+ const owner = recorded.slice(transcriptsKey.length)
96
+ await Promise.all(stored.filter((key) => key.startsWith(`${name}.`) && key.endsWith(`.sessions.${owner}`)).map((value) => claude.store.delete(value)))
97
+ await claude.store.delete(recorded)
98
+ }),
99
+ )
95
100
  }
96
101
 
97
102
  const loadGroups = async (only: readonly Lifetime[], next: { readonly root: string; readonly session: string }) => {
@@ -151,8 +156,10 @@ export function createState<State extends object>({ name, initial, session, root
151
156
  moveTo: (nextRoot) => inTurn(async () => (nextRoot === projectRoot ? undefined : loadGroups(['project'], { root: nextRoot, session: sessionId }))),
152
157
  switchSession: (nextSession, source, transcript) =>
153
158
  inTurn(async () => {
154
- await claude.store.set(`${transcriptsKey}${nextSession}`, transcript)
155
- await forgetEndedSessions(nextSession)
159
+ if (Object.keys(declared.session).length > 0) {
160
+ await claude.store.set(`${transcriptsKey}${nextSession}`, transcript)
161
+ void forgetEndedSessions(nextSession).catch((error: unknown) => report({ id: 'state:sessions', message: 'Could not forget the session values of ended sessions', error }))
162
+ }
156
163
  if (nextSession === sessionId) return
157
164
  if (source === 'fork') await copySession(sessionId, nextSession)
158
165
  return loadGroups(['memory', 'session'], { root: projectRoot, session: nextSession })
@@ -50,7 +50,11 @@ export const skillPrefix = 'Base directory for this skill:'
50
50
 
51
51
  const rowTypes: readonly string[] = ['user', 'assistant', 'attachment', 'system']
52
52
 
53
- const memoryPath = /^Contents of (.+?) \(/
53
+ const memoryPath = /^Contents of (.+?)(?: \([^)\n]*\))?:\r?$/m
54
+
55
+ export function instructionPathOf(text: string): string {
56
+ return memoryPath.exec(text)?.[1] ?? ''
57
+ }
54
58
 
55
59
  export function rowOfLine(row: Readonly<Record<string, unknown>>): Row | undefined {
56
60
  const { uuid, timestamp, type } = row
@@ -77,7 +81,7 @@ export function rowOfLine(row: Readonly<Record<string, unknown>>): Row | undefin
77
81
  export function rowOfAppend(append: { readonly uuid: string; readonly door: string; readonly origin: unknown; readonly message: { readonly type: Row['type']; readonly name?: string; readonly isMeta?: true; readonly content: readonly unknown[] } }, at: string, cwd: string): Row {
78
82
  const { message, origin } = append
79
83
  const text = textOf(message.content)
80
- const path = message.type === 'attachment' ? memoryPath.exec(text)?.[1] : undefined
84
+ const path = message.type === 'attachment' ? instructionPathOf(text) || undefined : undefined
81
85
  const model = isObject(origin) && origin['kind'] === 'model' && typeof origin['model'] === 'string' ? origin['model'] : undefined
82
86
  return {
83
87
  id: append.uuid,
package/src/testing.ts CHANGED
@@ -23,7 +23,8 @@ import type { Options, OptionValues } from './options.js'
23
23
  import { readSteps, storeFolder, type Steps } from './records.js'
24
24
  import type { Claude } from './runtime/claude.js'
25
25
  import { answerCall, cmodPluginName, notInstalled } from './runtime/dependencies.js'
26
- import { foldAnswers, installBefore } from './runtime/before.js'
26
+ import { closingContext, foldBefore, installBefore, withAnswer } from './runtime/before.js'
27
+ import { permissionRoutes } from './runtime/permission-check.js'
27
28
  import type { RoutedEvent } from './runtime/hooks.js'
28
29
  import { keepClaudeCode, type ClaudeCode } from './testing/claude-code.js'
29
30
  import { createRouter } from './runtime/router.js'
@@ -74,12 +75,14 @@ type MessageBefore = Exclude<BeforeName, 'TaskStart' | 'TurnEnd' | 'ContextEnd'>
74
75
 
75
76
  type DraftOf<Name extends MessageBefore> = Omit<Extract<MessageDraft, { readonly type: (typeof sessionEvents)[Name] }>, 'type'>
76
77
 
78
+ type RaisedOf<Name extends MessageBefore> = Name extends 'ToolResult' ? Omit<DraftOf<Name>, 'output'> & { readonly output: unknown } : DraftOf<Name>
79
+
77
80
  export type Raise = {
78
- readonly [Name in MessageBefore as `before${Name}`]: (message: DraftOf<Name>, options?: { readonly task?: string }) => Promise<AnswerOf<Name> | undefined>
81
+ readonly [Name in MessageBefore as `before${Name}`]: (message: RaisedOf<Name>, options?: { readonly task?: string }) => Promise<AnswerOf<Name> | undefined>
79
82
  } & {
80
83
  beforeTaskStart(task: { readonly title: string; readonly agentType?: string; readonly model?: string }): Promise<AnswerOf<'TaskStart'> | undefined>
81
84
  beforeTurnEnd(ending?: Pick<Turn, 'answer' | 'failure'>): Promise<AnswerOf<'TurnEnd'> | undefined>
82
- beforeContextEnd(): Promise<AnswerOf<'ContextEnd'> | undefined>
85
+ beforeContextEnd(options?: { readonly task?: string }): Promise<AnswerOf<'ContextEnd'> | undefined>
83
86
  } & {
84
87
  readonly [Name in ProcessName as `on${Name}`]: (payload: Placed<ProcessPayload[Name]>) => Promise<void>
85
88
  } & {
@@ -174,7 +177,7 @@ export function testMod<State extends object, Declared extends Options = Options
174
177
  const starting = options.state as Record<string, object> | undefined
175
178
  const declared = Object.entries(definition.state ?? {}) as [string, object][]
176
179
  const state = Object.fromEntries(declared.map(([lifetime, values]) => [lifetime, { ...values, ...starting?.[lifetime] }])) as State
177
- const lifecycle = createLifecycle<State, Declared>({ ...definition, state }, () => true, (options.options ?? {}) as PluginOptions, false)
180
+ const lifecycle = createLifecycle<State, Declared>({ ...definition, state }, () => permissionRoutes,(options.options ?? {}) as PluginOptions, false)
178
181
  let started: Promise<void> | undefined
179
182
 
180
183
  const start = async () => {
@@ -212,7 +215,7 @@ export function testMod<State extends object, Declared extends Options = Options
212
215
  hooks: {
213
216
  place: async () => conversations.place(),
214
217
  wants: async (event) => conversations.wants(name, event),
215
- decide: (event, change) => foldAnswers(event, change, [(latest) => askMod(event, latest)], (error) => fake.claude.ui.log(`cmod: passed the ${event} on without ${name}'s answer: ${messageOf(error)}`, { to: 'debug' })),
218
+ decide: (event, change) => foldBefore(event, change, [(latest) => askMod(event, latest)], (error) => fake.claude.ui.log(`cmod: passed the ${event} on without ${name}'s answer: ${messageOf(error)}`, { to: 'debug' })),
216
219
  },
217
220
  })
218
221
 
@@ -276,9 +279,11 @@ export function testMod<State extends object, Declared extends Options = Options
276
279
  const raise = Object.fromEntries([
277
280
  ...beforeEvents
278
281
  .filter((named) => named !== 'TaskStart' && named !== 'TurnEnd' && named !== 'ContextEnd')
279
- .map((named) => [`before${named}`, async (draft: object, placed?: { readonly task?: string }) => {
282
+ .map((named) => [`before${named}`, async (draft: { readonly output?: unknown }, placed?: { readonly task?: string }) => {
280
283
  await start()
281
- return asked(sessionEvents[named], { type: Message, message: conversations.draft({ ...draft, type: sessionEvents[named] } as MessageDraft, placed?.task) })
284
+ const isResult = named === 'ToolResult'
285
+ const message = conversations.draft({ ...draft, ...(isResult ? { output: '' } : {}), type: sessionEvents[named] } as MessageDraft, placed?.task)
286
+ return asked(sessionEvents[named], withAnswer({ type: Message, message }, { output: draft.output }))
282
287
  }]),
283
288
  ['beforeTaskStart', async ({ title, agentType = 'general-purpose', model }: { readonly title: string; readonly agentType?: string; readonly model?: string }) => {
284
289
  await start()
@@ -290,10 +295,12 @@ export function testMod<State extends object, Declared extends Options = Options
290
295
  if (turn === undefined) throw new Error(`${name} has no turn open to end. Open one with tested.add({ type: Prompt, text: '…' }).`)
291
296
  return asked(TurnEnd, { type: TurnEnd, turn: { ...turn, ...ending, closedAt: new Date().toISOString() } })
292
297
  }],
293
- ['beforeContextEnd', async () => {
298
+ ['beforeContextEnd', async (placed: { readonly task?: string } = {}) => {
294
299
  await start()
295
- const { context, contextOpenedAt } = conversations.place()
296
- return asked(ContextEnd, { type: ContextEnd, context: { number: context, openedAt: contextOpenedAt, closedAt: new Date().toISOString() } })
300
+ const where = conversations.place()
301
+ const task = placed.task === undefined ? undefined : where.tasks[placed.task]
302
+ if (placed.task !== undefined && task === undefined) throw new Error(`${name} has no task ${placed.task}. Start it with tested.startTask.`)
303
+ return asked(ContextEnd, { type: ContextEnd, context: closingContext(where, task, new Date().toISOString()) })
297
304
  }],
298
305
  ...[...Object.entries(processEvents).flatMap(([named, event]) => [`on${named}`, ...('before' in event ? [`before${named}`] : [])]), 'onRun', 'beforeRun'].map((method) => [method, async (payload: object) => {
299
306
  await start()
package/testing.d.ts CHANGED
@@ -38,8 +38,11 @@ type MessageBefore = Exclude<BeforeName, 'TaskStart' | 'TurnEnd' | 'ContextEnd'>
38
38
  type DraftOf<Name extends MessageBefore> = Omit<Extract<MessageDraft, {
39
39
  readonly type: (typeof sessionEvents)[Name];
40
40
  }>, 'type'>;
41
+ type RaisedOf<Name extends MessageBefore> = Name extends 'ToolResult' ? Omit<DraftOf<Name>, 'output'> & {
42
+ readonly output: unknown;
43
+ } : DraftOf<Name>;
41
44
  export type Raise = {
42
- readonly [Name in MessageBefore as `before${Name}`]: (message: DraftOf<Name>, options?: {
45
+ readonly [Name in MessageBefore as `before${Name}`]: (message: RaisedOf<Name>, options?: {
43
46
  readonly task?: string;
44
47
  }) => Promise<AnswerOf<Name> | undefined>;
45
48
  } & {
@@ -49,7 +52,9 @@ export type Raise = {
49
52
  readonly model?: string;
50
53
  }): Promise<AnswerOf<'TaskStart'> | undefined>;
51
54
  beforeTurnEnd(ending?: Pick<Turn, 'answer' | 'failure'>): Promise<AnswerOf<'TurnEnd'> | undefined>;
52
- beforeContextEnd(): Promise<AnswerOf<'ContextEnd'> | undefined>;
55
+ beforeContextEnd(options?: {
56
+ readonly task?: string;
57
+ }): Promise<AnswerOf<'ContextEnd'> | undefined>;
53
58
  } & {
54
59
  readonly [Name in ProcessName as `on${Name}`]: (payload: Placed<ProcessPayload[Name]>) => Promise<void>;
55
60
  } & {
package/testing.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["src/testing.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,IAAI,EAEJ,gBAAgB,EAGhB,WAAW,EASX,eAAe,EAGhB,MAAM,aAAa,CAAA;AACpB,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAA;AAC3D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAC7C,OAAO,KAAK,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAYzD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAA;AACtD,OAAO,EAAE,OAAO,EAAE,MAAM,2CAA2C,CAAA;AAKnE,OAAO,EAAW,KAAK,IAAI,EAAE,MAAM,gCAAgC,CAAA;AACnE,OAAO,EAA+B,aAAa,EAAE,KAAK,QAAQ,EAAoB,KAAK,UAAU,EAAE,KAAK,UAAU,EAAE,KAAK,gBAAgB,EAAE,KAAK,SAAS,EAAE,KAAK,aAAa,EAAE,KAAK,YAAY,EAAE,KAAK,WAAW,EAAE,KAAK,cAAc,EAAE,MAAM,sBAAsB,CAAA;AAGzQ,OAAO,EAA4B,KAAK,KAAK,EAAE,KAAK,KAAK,IAAI,aAAa,EAAE,KAAK,QAAQ,EAAE,MAAM,0BAA0B,CAAA;AAG3H,OAAO,EAAgB,KAAK,WAAW,EAAE,KAAK,YAAY,EAAE,KAAK,YAAY,EAAE,MAAM,4BAA4B,CAAA;AAEjH,OAAO,EAAe,KAAK,cAAc,EAAE,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAA;AAElF,YAAY,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,0BAA0B,CAAA;AAC/D,YAAY,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAA;AAC3E,YAAY,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AACrD,YAAY,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAA;AAEtD,MAAM,MAAM,KAAK,GAAG,aAAa,GAAG;IAAE,QAAQ,CAAC,MAAM,EAAE,SAAS,UAAU,EAAE,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAC,MAAM,CAAC,CAAA;CAAE,CAAA;AAEnH,MAAM,MAAM,WAAW,CAAC,KAAK,SAAS,MAAM,EAAE,QAAQ,SAAS,OAAO,GAAG,OAAO,IAAI;IAClF,QAAQ,CAAC,KAAK,CAAC,EAAE;QAAE,QAAQ,EAAE,QAAQ,IAAI,MAAM,KAAK,CAAC,CAAC,EAAE,OAAO,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;KAAE,CAAA;IAClF,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAC,CAAA;IAClD,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IACxC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACnC,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,YAAY,CAAC,EAAE;QAAE,QAAQ,EAAE,IAAI,IAAI,MAAM,gBAAgB,CAAC,CAAC,EAAE,gBAAgB,CAAC,IAAI,CAAC;KAAE,CAAA;IAC9F,QAAQ,CAAC,KAAK,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;IACjD,QAAQ,CAAC,KAAK,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;IACjD,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,WAAW,EAAE,CAAA;CAC3C,CAAA;AAED,KAAK,MAAM,CAAC,OAAO,IAAI,IAAI,CAAC,OAAO,EAAE,MAAM,SAAS,CAAC,GAAG,OAAO,CAAC,SAAS,CAAC,CAAA;AAE1E,KAAK,aAAa,GAAG,OAAO,CAAC,UAAU,EAAE,WAAW,GAAG,SAAS,GAAG,YAAY,CAAC,CAAA;AAEhF,KAAK,OAAO,CAAC,IAAI,SAAS,aAAa,IAAI,IAAI,CAAC,OAAO,CAAC,YAAY,EAAE;IAAE,QAAQ,CAAC,IAAI,EAAE,CAAC,OAAO,aAAa,CAAC,CAAC,IAAI,CAAC,CAAA;CAAE,CAAC,EAAE,MAAM,CAAC,CAAA;AAE/H,MAAM,MAAM,KAAK,GAAG;IAClB,QAAQ,EAAE,IAAI,IAAI,aAAa,IAAI,SAAS,IAAI,EAAE,GAAG,CAAC,OAAO,EAAE,OAAO,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,GAAG,SAAS,CAAC;CAC3J,GAAG;IACF,eAAe,CAAC,IAAI,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC,GAAG,SAAS,CAAC,CAAA;IACnJ,aAAa,CAAC,MAAM,CAAC,EAAE,IAAI,CAAC,IAAI,EAAE,QAAQ,GAAG,SAAS,CAAC,GAAG,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC,GAAG,SAAS,CAAC,CAAA;IAClG,gBAAgB,IAAI,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC,GAAG,SAAS,CAAC,CAAA;CAChE,GAAG;IACF,QAAQ,EAAE,IAAI,IAAI,WAAW,IAAI,KAAK,IAAI,EAAE,GAAG,CAAC,OAAO,EAAE,MAAM,CAAC,cAAc,CAAC,IAAI,CAAC,CAAC,KAAK,OAAO,CAAC,IAAI,CAAC;CACxG,GAAG;IACF,QAAQ,EAAE,IAAI,IAAI,MAAM,aAAa,IAAI,SAAS,IAAI,EAAE,GAAG,CAAC,OAAO,EAAE,MAAM,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC,KAAK,OAAO,CAAC,aAAa,CAAC,IAAI,CAAC,GAAG,SAAS,CAAC;CAC7I,GAAG;IACF,KAAK,CAAC,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC7C,SAAS,CAAC,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC,GAAG,OAAO,CAAC,gBAAgB,GAAG,SAAS,CAAC,CAAA;CAC1E,CAAA;AAED,KAAK,UAAU,GAAG,WAAW,GAAG,WAAW,GAAG,UAAU,GAAG,WAAW,GAAG,YAAY,GAAG,gBAAgB,CAAA;AAExG,MAAM,MAAM,SAAS,CAAC,KAAK,SAAS,MAAM,IAAI;IAC5C,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACtB,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAA;IACrB,IAAI,CAAC,CAAC,SAAS,UAAU,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC,CAAC,CAAC,EAAE,KAAK,CAAC,EAAE,WAAW,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,CAAA;IACrG,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACvB,GAAG,CAAC,OAAO,EAAE,YAAY,EAAE,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC,CAAA;IACpF,OAAO,CAAC,MAAM,CAAC,EAAE,IAAI,CAAC,IAAI,EAAE,QAAQ,GAAG,SAAS,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACjE,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACxB,YAAY,CAAC,KAAK,EAAE;QAAE,QAAQ,CAAC,MAAM,EAAE,SAAS,GAAG,QAAQ,GAAG,OAAO,GAAG,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACtH,SAAS,CAAC,IAAI,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IACxK,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAA;IACxC,KAAK,CAAC,CAAC,SAAS,cAAc,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,SAAS,CAAC,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAA;IACvH,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAAA;IACzC,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,WAAW,CAAC,WAAW,CAAC,CAAC,CAAA;IACzF,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAChE,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,eAAe,CAAC,MAAM,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC/F,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACjE,MAAM,CAAC,WAAW,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACxD,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC,KAAK,CAAC,CAAA;IAC/B,QAAQ,CAAC,KAAK,EAAE,SAAS,QAAQ,EAAE,CAAA;IACnC,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAA;IACrB,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAA;CACtB,CAAA;AAUD,wBAAgB,OAAO,CAAC,KAAK,SAAS,MAAM,EAAE,QAAQ,SAAS,OAAO,GAAG,OAAO,EAAE,UAAU,EAAE,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,OAAO,GAAE,WAAW,CAAC,KAAK,EAAE,QAAQ,CAAM,GAAG,SAAS,CAAC,KAAK,CAAC,CAuTlM"}
1
+ {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["src/testing.ts"],"names":[],"mappings":"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"}
package/testing.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readSteps, storeFolder } from './records.js';
2
2
  import { answerCall, cmodPluginName, notInstalled } from './runtime/dependencies.js';
3
- import { foldAnswers, installBefore } from './runtime/before.js';
3
+ import { closingContext, foldBefore, installBefore, withAnswer } from './runtime/before.js';
4
+ import { permissionRoutes } from './runtime/permission-check.js';
4
5
  import { keepClaudeCode } from './testing/claude-code.js';
5
6
  import { createRouter } from './runtime/router.js';
6
7
  import { toolInputOf } from './runtime/tool-calls.js';
@@ -79,7 +80,7 @@ export function testMod(definition, options = {}) {
79
80
  const starting = options.state;
80
81
  const declared = Object.entries(definition.state ?? {});
81
82
  const state = Object.fromEntries(declared.map(([lifetime, values]) => [lifetime, { ...values, ...starting?.[lifetime] }]));
82
- const lifecycle = createLifecycle({ ...definition, state }, () => true, (options.options ?? {}), false);
83
+ const lifecycle = createLifecycle({ ...definition, state }, () => permissionRoutes, (options.options ?? {}), false);
83
84
  let started;
84
85
  const start = async () => {
85
86
  started ??= lifecycle.start(fake.claude, async (claude) => {
@@ -116,7 +117,7 @@ export function testMod(definition, options = {}) {
116
117
  hooks: {
117
118
  place: async () => conversations.place(),
118
119
  wants: async (event) => conversations.wants(name, event),
119
- decide: (event, change) => foldAnswers(event, change, [(latest) => askMod(event, latest)], (error) => fake.claude.ui.log(`cmod: passed the ${event} on without ${name}'s answer: ${messageOf(error)}`, { to: 'debug' })),
120
+ decide: (event, change) => foldBefore(event, change, [(latest) => askMod(event, latest)], (error) => fake.claude.ui.log(`cmod: passed the ${event} on without ${name}'s answer: ${messageOf(error)}`, { to: 'debug' })),
120
121
  },
121
122
  });
122
123
  const route = async (event, input, below) => {
@@ -178,7 +179,9 @@ export function testMod(definition, options = {}) {
178
179
  .filter((named) => named !== 'TaskStart' && named !== 'TurnEnd' && named !== 'ContextEnd')
179
180
  .map((named) => [`before${named}`, async (draft, placed) => {
180
181
  await start();
181
- return asked(sessionEvents[named], { type: Message, message: conversations.draft({ ...draft, type: sessionEvents[named] }, placed?.task) });
182
+ const isResult = named === 'ToolResult';
183
+ const message = conversations.draft({ ...draft, ...(isResult ? { output: '' } : {}), type: sessionEvents[named] }, placed?.task);
184
+ return asked(sessionEvents[named], withAnswer({ type: Message, message }, { output: draft.output }));
182
185
  }]),
183
186
  ['beforeTaskStart', async ({ title, agentType = 'general-purpose', model }) => {
184
187
  await start();
@@ -191,10 +194,13 @@ export function testMod(definition, options = {}) {
191
194
  throw new Error(`${name} has no turn open to end. Open one with tested.add({ type: Prompt, text: '…' }).`);
192
195
  return asked(TurnEnd, { type: TurnEnd, turn: { ...turn, ...ending, closedAt: new Date().toISOString() } });
193
196
  }],
194
- ['beforeContextEnd', async () => {
197
+ ['beforeContextEnd', async (placed = {}) => {
195
198
  await start();
196
- const { context, contextOpenedAt } = conversations.place();
197
- return asked(ContextEnd, { type: ContextEnd, context: { number: context, openedAt: contextOpenedAt, closedAt: new Date().toISOString() } });
199
+ const where = conversations.place();
200
+ const task = placed.task === undefined ? undefined : where.tasks[placed.task];
201
+ if (placed.task !== undefined && task === undefined)
202
+ throw new Error(`${name} has no task ${placed.task}. Start it with tested.startTask.`);
203
+ return asked(ContextEnd, { type: ContextEnd, context: closingContext(where, task, new Date().toISOString()) });
198
204
  }],
199
205
  ...[...Object.entries(processEvents).flatMap(([named, event]) => [`on${named}`, ...('before' in event ? [`before${named}`] : [])]), 'onRun', 'beforeRun'].map((method) => [method, async (payload) => {
200
206
  await start();