@pikku/core 0.12.69 → 0.12.70

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 (84) hide show
  1. package/CHANGELOG.md +334 -0
  2. package/README.md +34 -2
  3. package/dist/function/functions.types.d.ts +27 -0
  4. package/dist/index.d.ts +1 -1
  5. package/dist/internal.d.ts +1 -1
  6. package/dist/internal.js +1 -1
  7. package/dist/pikku-state.js +1 -0
  8. package/dist/services/http-scenario-actors.d.ts +12 -4
  9. package/dist/services/http-scenario-actors.js +47 -45
  10. package/dist/services/index.d.ts +2 -1
  11. package/dist/services/index.js +1 -0
  12. package/dist/services/meta-service.d.ts +5 -1
  13. package/dist/services/meta-service.js +44 -18
  14. package/dist/services/scenario-actors-service.d.ts +108 -2
  15. package/dist/services/scenario-actors-service.js +40 -1
  16. package/dist/types/core.types.d.ts +21 -3
  17. package/dist/types/state.types.d.ts +3 -1
  18. package/dist/wirings/actor-flow/actor-flow.types.d.ts +1 -1
  19. package/dist/wirings/actor-flow/index.d.ts +1 -1
  20. package/dist/wirings/actor-flow/run-conversation.d.ts +10 -10
  21. package/dist/wirings/actor-flow/run-conversation.js +27 -27
  22. package/dist/wirings/cli/command-parser.js +11 -1
  23. package/dist/wirings/rpc/rpc-runner.js +1 -1
  24. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +52 -3
  25. package/dist/wirings/workflow/feature.d.ts +28 -0
  26. package/dist/wirings/workflow/feature.js +57 -0
  27. package/dist/wirings/workflow/index.d.ts +13 -2
  28. package/dist/wirings/workflow/index.js +15 -0
  29. package/dist/wirings/workflow/pikku-scenario-service.d.ts +121 -0
  30. package/dist/wirings/workflow/pikku-scenario-service.js +419 -0
  31. package/dist/wirings/workflow/pikku-workflow-service.d.ts +103 -10
  32. package/dist/wirings/workflow/pikku-workflow-service.js +80 -135
  33. package/dist/wirings/workflow/scenario-cookie-jar.d.ts +29 -0
  34. package/dist/wirings/workflow/scenario-cookie-jar.js +51 -0
  35. package/dist/wirings/workflow/scenario-poll.d.ts +20 -0
  36. package/dist/wirings/workflow/scenario-poll.js +25 -0
  37. package/dist/wirings/workflow/scenario-prose.d.ts +38 -0
  38. package/dist/wirings/workflow/scenario-prose.js +45 -0
  39. package/dist/wirings/workflow/scenario-step-guards.d.ts +16 -0
  40. package/dist/wirings/workflow/scenario-step-guards.js +29 -0
  41. package/dist/wirings/workflow/scenario-step.types.d.ts +148 -0
  42. package/dist/wirings/workflow/scenario-step.types.js +1 -0
  43. package/dist/wirings/workflow/workflow.types.d.ts +81 -2
  44. package/package.json +3 -1
  45. package/src/function/functions.types.ts +32 -0
  46. package/src/index.ts +1 -0
  47. package/src/internal.ts +5 -1
  48. package/src/pikku-state.ts +1 -0
  49. package/src/services/http-scenario-actors.test.ts +85 -1
  50. package/src/services/http-scenario-actors.ts +65 -51
  51. package/src/services/index.ts +5 -0
  52. package/src/services/meta-service.test.ts +79 -0
  53. package/src/services/meta-service.ts +61 -26
  54. package/src/services/scenario-actors-service.ts +157 -2
  55. package/src/types/core.types.ts +27 -2
  56. package/src/types/state.types.ts +3 -0
  57. package/src/wirings/actor-flow/actor-flow.types.ts +1 -1
  58. package/src/wirings/actor-flow/index.ts +1 -1
  59. package/src/wirings/actor-flow/run-conversation.test.ts +12 -6
  60. package/src/wirings/actor-flow/run-conversation.ts +36 -41
  61. package/src/wirings/cli/command-parser.test.ts +60 -0
  62. package/src/wirings/cli/command-parser.ts +12 -1
  63. package/src/wirings/rpc/rpc-runner.test.ts +28 -5
  64. package/src/wirings/rpc/rpc-runner.ts +1 -1
  65. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +86 -2
  66. package/src/wirings/workflow/feature.test.ts +131 -0
  67. package/src/wirings/workflow/feature.ts +78 -0
  68. package/src/wirings/workflow/index.ts +73 -0
  69. package/src/wirings/workflow/pikku-scenario-service.ts +682 -0
  70. package/src/wirings/workflow/pikku-workflow-service.test.ts +55 -0
  71. package/src/wirings/workflow/pikku-workflow-service.ts +196 -208
  72. package/src/wirings/workflow/scenario-cookie-jar.test.ts +108 -0
  73. package/src/wirings/workflow/scenario-cookie-jar.ts +65 -0
  74. package/src/wirings/workflow/scenario-hooks.test.ts +212 -0
  75. package/src/wirings/workflow/scenario-poll.test.ts +66 -0
  76. package/src/wirings/workflow/scenario-poll.ts +36 -0
  77. package/src/wirings/workflow/scenario-prose.test.ts +152 -0
  78. package/src/wirings/workflow/scenario-prose.ts +79 -0
  79. package/src/wirings/workflow/scenario-service.test.ts +155 -0
  80. package/src/wirings/workflow/scenario-step-guards.ts +43 -0
  81. package/src/wirings/workflow/scenario-step.test.ts +441 -8
  82. package/src/wirings/workflow/scenario-step.types.ts +157 -0
  83. package/src/wirings/workflow/workflow.types.ts +98 -1
  84. package/tsconfig.tsbuildinfo +1 -1
@@ -11,8 +11,8 @@ import type {
11
11
  AIAgentStepResult,
12
12
  } from '../../services/ai-agent-runner-service.js'
13
13
 
14
- /** One turn the persona takes: the message to send and whether it's finished. */
15
- const PERSONA_TURN_SCHEMA = {
14
+ /** One turn the actor takes: the message to send and whether it's finished. */
15
+ const ACTOR_TURN_SCHEMA = {
16
16
  type: 'object',
17
17
  properties: {
18
18
  message: { type: 'string' },
@@ -21,7 +21,7 @@ const PERSONA_TURN_SCHEMA = {
21
21
  required: ['message', 'done'],
22
22
  } as const
23
23
 
24
- /** The persona's approve/deny decision for each pending tool request. */
24
+ /** The actor's approve/deny decision for each pending tool request. */
25
25
  const APPROVAL_DECISION_SCHEMA = {
26
26
  type: 'object',
27
27
  properties: {
@@ -41,7 +41,7 @@ const APPROVAL_DECISION_SCHEMA = {
41
41
  required: ['decisions'],
42
42
  } as const
43
43
 
44
- /** The persona's final verdict on whether the task was accomplished. */
44
+ /** The actor's final verdict on whether the task was accomplished. */
45
45
  const EVALUATION_SCHEMA = {
46
46
  type: 'object',
47
47
  properties: {
@@ -55,23 +55,23 @@ const DEFAULT_MAX_TURNS = 12
55
55
 
56
56
  const DEFAULT_MAX_APPROVAL_ROUNDS = 16
57
57
 
58
- /** The LLM call the persona uses for its own turns/decisions/evaluation. */
59
- export type PersonaLLM = (
58
+ /** The LLM call the actor uses for its own turns/decisions/evaluation. */
59
+ export type ActorLLM = (
60
60
  params: AIAgentRunnerParams
61
61
  ) => Promise<AIAgentStepResult>
62
62
 
63
63
  export interface RunConversationParams {
64
- /** Persona config (personality/jobTitle/name) that shapes how the actor talks. */
65
- persona: ScenarioActorConfig
66
- /** Stable persona name (for transcript labelling). */
67
- personaName: string
64
+ /** The actor's own config (personality/jobTitle/name), which shapes how it talks. */
65
+ actor: ScenarioActorConfig
66
+ /** Stable actor name (for transcript labelling). */
67
+ actorName: string
68
68
  /** What the actor is trying to get the target agent to accomplish. */
69
69
  task: string
70
70
  /** Natural-language success criterion the actor evaluates at the end. */
71
71
  evaluate: string
72
72
  /** How the actor answers the target agent's tool-approval requests. */
73
73
  approvals?: ActorFlowApprovalPolicy
74
- /** Model the persona uses for its own turns/decisions. */
74
+ /** Model the actor uses for its own turns/decisions. */
75
75
  model: string
76
76
  /** Hard cap on conversation turns. Default 12. */
77
77
  maxTurns?: number
@@ -79,8 +79,8 @@ export interface RunConversationParams {
79
79
  maxApprovalRounds?: number
80
80
  /** Transport that drives the target agent (HTTP in production). */
81
81
  target: TargetAgentDriver
82
- /** The persona's own LLM. */
83
- llm: PersonaLLM
82
+ /** The actor's own LLM. */
83
+ llm: ActorLLM
84
84
  /** Display name of the target agent (transcript labelling). */
85
85
  agentName: string
86
86
  }
@@ -109,16 +109,13 @@ function readObject<T>(result: { object?: unknown; text?: string }): T | null {
109
109
  return null
110
110
  }
111
111
 
112
- function personaInstructions(
113
- persona: ScenarioActorConfig,
114
- task: string
115
- ): string {
112
+ function actorInstructions(actor: ScenarioActorConfig, task: string): string {
116
113
  return [
117
114
  `You are role-playing a real user interacting with an AI assistant. Stay in character at all times — you are the user, not the assistant.`,
118
- persona.name ? `Your name is ${persona.name}.` : '',
119
- persona.jobTitle ? `Your role: ${persona.jobTitle}.` : '',
120
- persona.personality
121
- ? `Your personality and communication style: ${persona.personality}. Match this tone, vocabulary, and level of detail exactly.`
115
+ actor.name ? `Your name is ${actor.name}.` : '',
116
+ actor.jobTitle ? `Your role: ${actor.jobTitle}.` : '',
117
+ actor.personality
118
+ ? `Your personality and communication style: ${actor.personality}. Match this tone, vocabulary, and level of detail exactly.`
122
119
  : '',
123
120
  `Your goal in this conversation: ${task}.`,
124
121
  `Send one message at a time. Set "done" to true only once your goal is clearly accomplished, or clearly impossible.`,
@@ -127,7 +124,7 @@ function personaInstructions(
127
124
  .join('\n')
128
125
  }
129
126
 
130
- /** Route the target agent's pending tool approvals through the persona. */
127
+ /** Route the target agent's pending tool approvals through the actor. */
131
128
  async function decideApprovals(
132
129
  params: RunConversationParams,
133
130
  instructions: string,
@@ -150,7 +147,7 @@ async function decideApprovals(
150
147
 
151
148
  const result = await params.llm({
152
149
  model: params.model,
153
- instructions: `${instructions}\nThe assistant is asking permission to run tools on your behalf. Decide whether YOU, as this persona, would allow each one.`,
150
+ instructions: `${instructions}\nThe assistant is asking permission to run tools on your behalf. Decide whether YOU, as this actor, would allow each one.`,
154
151
  messages: [
155
152
  msg(
156
153
  'user',
@@ -178,7 +175,7 @@ async function decideApprovals(
178
175
  })
179
176
  }
180
177
 
181
- /** Drive the target to a non-suspended reply, routing approvals to the persona. */
178
+ /** Drive the target to a non-suspended reply, routing approvals to the actor. */
182
179
  async function converseWithTarget(
183
180
  params: RunConversationParams,
184
181
  instructions: string,
@@ -209,7 +206,7 @@ async function converseWithTarget(
209
206
  }
210
207
 
211
208
  /**
212
- * Run a conversation: an LLM-driven persona holds a real multi-turn exchange
209
+ * Run a conversation: an LLM-driven actor holds a real multi-turn exchange
213
210
  * with a target agent (driven via the injected transport), answers the target's
214
211
  * tool-approval requests in-persona, then evaluates whether the task was met.
215
212
  * Deterministic checks are the caller's responsibility.
@@ -218,11 +215,11 @@ export async function runConversation(
218
215
  params: RunConversationParams
219
216
  ): Promise<ActorFlowVerdict> {
220
217
  const maxTurns = params.maxTurns ?? DEFAULT_MAX_TURNS
221
- const instructions = personaInstructions(params.persona, params.task)
222
- // Seed a kickoff so the very first persona turn has a non-empty message list
223
- // (providers reject an empty prompt). It's an instruction TO the persona, so
218
+ const instructions = actorInstructions(params.actor, params.task)
219
+ // Seed a kickoff so the very first actor turn has a non-empty message list
220
+ // (providers reject an empty prompt). It's an instruction TO the actor, so
224
221
  // it never appears in the transcript.
225
- const personaMessages: AIMessage[] = [
222
+ const actorMessages: AIMessage[] = [
226
223
  msg(
227
224
  'user',
228
225
  'Begin the conversation now — send your first message to the assistant to work towards your goal.'
@@ -231,29 +228,27 @@ export async function runConversation(
231
228
  const transcript: string[] = []
232
229
 
233
230
  for (let turn = 0; turn < maxTurns; turn++) {
234
- const personaResult = await params.llm({
231
+ const actorResult = await params.llm({
235
232
  model: params.model,
236
233
  instructions,
237
- messages: personaMessages,
234
+ messages: actorMessages,
238
235
  tools: [],
239
236
  maxSteps: 1,
240
237
  toolChoice: 'none',
241
- outputSchema: PERSONA_TURN_SCHEMA as unknown as Record<string, unknown>,
238
+ outputSchema: ACTOR_TURN_SCHEMA as unknown as Record<string, unknown>,
242
239
  })
243
240
 
244
- const turnData = readObject<{ message: string; done: boolean }>(
245
- personaResult
246
- )
247
- const personaMessage = turnData?.message?.trim()
248
- if (!personaMessage) {
241
+ const turnData = readObject<{ message: string; done: boolean }>(actorResult)
242
+ const actorMessage = turnData?.message?.trim()
243
+ if (!actorMessage) {
249
244
  break
250
245
  }
251
246
 
252
- personaMessages.push(msg('assistant', personaMessage))
253
- transcript.push(`${params.personaName}: ${personaMessage}`)
247
+ actorMessages.push(msg('assistant', actorMessage))
248
+ transcript.push(`${params.actorName}: ${actorMessage}`)
254
249
 
255
- const reply = await converseWithTarget(params, instructions, personaMessage)
256
- personaMessages.push(msg('user', reply.text ?? ''))
250
+ const reply = await converseWithTarget(params, instructions, actorMessage)
251
+ actorMessages.push(msg('user', reply.text ?? ''))
257
252
  transcript.push(`${params.agentName}: ${reply.text ?? ''}`)
258
253
 
259
254
  if (turnData?.done) {
@@ -74,6 +74,20 @@ const testMeta: CLIMeta = {
74
74
  positionals: [{ name: 'name', required: false }],
75
75
  options: {},
76
76
  },
77
+ serve: {
78
+ pikkuFuncId: 'serveFunc',
79
+ positionals: [],
80
+ options: {
81
+ browser: {
82
+ description: 'Open a browser',
83
+ default: true,
84
+ },
85
+ port: {
86
+ description: 'Port to listen on',
87
+ default: 8080,
88
+ },
89
+ },
90
+ },
77
91
  },
78
92
  },
79
93
  },
@@ -103,6 +117,52 @@ describe('Command Parser', () => {
103
117
  assert.strictEqual(result.errors.length, 0)
104
118
  })
105
119
 
120
+ test('should turn a boolean option off with --no-<flag>', () => {
121
+ const result = parseCLIArguments(
122
+ ['serve', '--no-browser'],
123
+ 'test-cli',
124
+ testMeta
125
+ )
126
+
127
+ assert.strictEqual(result.options.browser, false)
128
+ assert.strictEqual(result.errors.length, 0)
129
+ assert.deepStrictEqual(
130
+ result.warnings ?? [],
131
+ [],
132
+ '--no-browser is the negation of a known option, not an unknown one'
133
+ )
134
+ })
135
+
136
+ test('should leave a boolean option at its default when not negated', () => {
137
+ const result = parseCLIArguments(['serve'], 'test-cli', testMeta)
138
+
139
+ assert.strictEqual(result.options.browser, true)
140
+ })
141
+
142
+ test('should not consume the next argument when negating', () => {
143
+ const result = parseCLIArguments(
144
+ ['serve', '--no-browser', '--port', '4077'],
145
+ 'test-cli',
146
+ testMeta
147
+ )
148
+
149
+ assert.strictEqual(result.options.browser, false)
150
+ assert.strictEqual(result.options.port, 4077)
151
+ })
152
+
153
+ test('should still warn about an unknown --no-<flag>', () => {
154
+ const result = parseCLIArguments(
155
+ ['serve', '--no-telemetry'],
156
+ 'test-cli',
157
+ testMeta
158
+ )
159
+
160
+ assert.ok(
161
+ (result.warnings ?? []).length > 0,
162
+ 'negating an option that does not exist is a typo, not a feature'
163
+ )
164
+ })
165
+
106
166
  test('should parse command with short flag', () => {
107
167
  const result = parseCLIArguments(
108
168
  ['greet', 'Alice', '-l'],
@@ -210,8 +210,19 @@ export function parseCLIArguments(
210
210
 
211
211
  if (arg.startsWith('--')) {
212
212
  // Long option (--from-plan → fromPlan)
213
+ const negatedKey = arg.startsWith('--no-')
214
+ ? toCamelCase(arg.slice(5))
215
+ : undefined
213
216
  const equalIndex = arg.indexOf('=')
214
- if (equalIndex > 0) {
217
+ if (
218
+ negatedKey &&
219
+ typeof availableOptions[negatedKey]?.default === 'boolean'
220
+ ) {
221
+ // --no-<flag> turns a boolean option off. Only options that declare a
222
+ // boolean default can be negated, so a literal `--no-something` option
223
+ // name still parses as itself.
224
+ optionArgs[negatedKey] = false
225
+ } else if (equalIndex > 0) {
215
226
  // --option=value format
216
227
  const key = toCamelCase(arg.slice(2, equalIndex))
217
228
  const optionDef = availableOptions[key]
@@ -29,11 +29,13 @@ const registerFunction = (
29
29
  expose,
30
30
  pikkuFuncId,
31
31
  tags,
32
+ scenarioStep,
32
33
  }: {
33
34
  packageName?: string | null
34
35
  expose?: boolean
35
36
  pikkuFuncId?: string
36
37
  tags?: string[]
38
+ scenarioStep?: boolean
37
39
  } = {}
38
40
  ) => {
39
41
  addFunction(funcName, { func } as never, packageName)
@@ -44,6 +46,7 @@ const registerFunction = (
44
46
  permissions: [],
45
47
  expose,
46
48
  tags,
49
+ scenarioStep,
47
50
  } as never
48
51
  }
49
52
 
@@ -312,6 +315,27 @@ describe('ContextAwareRPCService.rpcExposed', () => {
312
315
  )
313
316
  })
314
317
 
318
+ test('a scenario step is not callable over the network, even if marked exposed', async () => {
319
+ // A step may drive a browser or assert against fixtures, so it is a step
320
+ // RPC — dispatched by name inside a run, never reachable from outside it.
321
+ pikkuState(null, 'rpc', 'meta').seesAddonCard = 'seesAddonCard'
322
+ registerFunction('seesAddonCard', async () => ({ visible: true }), {
323
+ expose: true,
324
+ scenarioStep: true,
325
+ })
326
+
327
+ const service = new ContextAwareRPCService(
328
+ createServices(),
329
+ {} as never,
330
+ {}
331
+ )
332
+
333
+ await assert.rejects(
334
+ () => service.rpcExposed('seesAddonCard', {}),
335
+ RPCNotFoundError
336
+ )
337
+ })
338
+
315
339
  test('runs exposed addon functions', async () => {
316
340
  pikkuState(null, 'addons', 'packages').set('addon', {
317
341
  package: '@addon/pkg',
@@ -669,7 +693,9 @@ describe('wireRemoteAddon dispatch', () => {
669
693
  {}
670
694
  )
671
695
 
672
- const result = await service.rpc('registry:getOpenApi', { name: 'stripe' })
696
+ const result = await service.rpc('registry:getOpenApi', {
697
+ name: 'stripe',
698
+ })
673
699
 
674
700
  assert.deepEqual(result, { echoed: 42 })
675
701
  assert.equal(calls.length, 1)
@@ -713,10 +739,7 @@ describe('wireRemoteAddon dispatch', () => {
713
739
  )
714
740
 
715
741
  await service.rpc('registry:getOpenApi', {})
716
- assert.equal(
717
- calls[0]!.init.headers.authorization,
718
- 'Bearer user-token-9'
719
- )
742
+ assert.equal(calls[0]!.init.headers.authorization, 'Bearer user-token-9')
720
743
  } finally {
721
744
  restoreFetch()
722
745
  }
@@ -165,7 +165,7 @@ export class ContextAwareRPCService {
165
165
  if (!functionMeta) {
166
166
  throw new RPCNotFoundError(funcName)
167
167
  }
168
- if (!functionMeta.expose) {
168
+ if (!functionMeta.expose || functionMeta.scenarioStep) {
169
169
  throw new RPCNotFoundError(funcName)
170
170
  }
171
171
  return await this.rpc(funcName, data)
@@ -7,6 +7,10 @@ import type { StandardSchemaV1 } from '@standard-schema/spec'
7
7
 
8
8
  import type { WorkflowRun } from '../workflow.types.js'
9
9
  import type { ScenarioActor } from '../../../services/scenario-actors-service.js'
10
+ import type {
11
+ ScenarioStepOptions,
12
+ ScenarioStepPhase,
13
+ } from '../scenario-step.types.js'
10
14
 
11
15
  /**
12
16
  * Workflow step options
@@ -79,6 +83,19 @@ export type WorkflowWireDoInline = <T>(
79
83
  options?: WorkflowStepOptions
80
84
  ) => Promise<T>
81
85
 
86
+ /**
87
+ * Type signature for scenario.step/given/when/then - used by inspector.
88
+ *
89
+ * Deliberately mirrors WorkflowWireDoRPC's shape: the target is a string, not
90
+ * an imported symbol, so the extractor reads it as a literal.
91
+ */
92
+ export type ScenarioStepInvocation = <TOutput = any, TInput = any>(
93
+ stepName: string,
94
+ stepFunc: string,
95
+ data?: TInput,
96
+ options?: ScenarioStepOptions
97
+ ) => Promise<TOutput>
98
+
82
99
  /**
83
100
  * Type signature for workflow.sleep() - used by inspector
84
101
  */
@@ -180,6 +197,34 @@ export interface RpcStepMeta {
180
197
  expectEventually?: boolean
181
198
  }
182
199
 
200
+ /**
201
+ * Scenario step metadata — a call to `scenario.step/given/when/then`.
202
+ *
203
+ * Distinct from RpcStepMeta on purpose: a step runs locally through
204
+ * runPikkuFunc and must never be treated as dispatchable on the queue/replay
205
+ * path, nor registered as a callable RPC (a browser-driving step must not be
206
+ * network-invocable).
207
+ */
208
+ export interface ScenarioStepMeta {
209
+ type: 'scenarioStep'
210
+ /** Cache key (first argument), ordinal-suffixed by the engine when repeated */
211
+ stepName: string
212
+ /** Registered name of the step function being run */
213
+ stepFunc: string
214
+ /** Which keyword the reporter prefixes — given/when/then, or none for `step` */
215
+ phase: ScenarioStepPhase
216
+ /** Output variable name (if assigned) */
217
+ outputVar?: string
218
+ /** Input source mappings, or 'passthrough' when entire data is passed */
219
+ inputs?: Record<string, InputSource> | 'passthrough'
220
+ /** Step options */
221
+ options?: WorkflowStepOptions
222
+ /** Scenario actor name this step runs as ({ actor: actors.x }) */
223
+ actor?: string
224
+ /** Mirrors the step function's `browser: true` declaration */
225
+ browser?: boolean
226
+ }
227
+
183
228
  /**
184
229
  * Simple condition expression (leaf node)
185
230
  */
@@ -223,7 +268,7 @@ export interface BranchStepMeta {
223
268
  export interface ParallelGroupStepMeta {
224
269
  type: 'parallel'
225
270
  /** Child steps to execute in parallel */
226
- children: RpcStepMeta[]
271
+ children: Array<RpcStepMeta | ScenarioStepMeta>
227
272
  }
228
273
 
229
274
  /**
@@ -244,7 +289,7 @@ export interface FanoutStepMeta {
244
289
  /** Execution mode */
245
290
  mode: 'parallel' | 'sequential'
246
291
  /** Steps to execute inline per iteration, in order */
247
- body: Array<RpcStepMeta | SleepStepMeta | SuspendStepMeta>
292
+ body: Array<RpcStepMeta | SleepStepMeta | SuspendStepMeta | ScenarioStepMeta>
248
293
  /** Time between iterations (sequential mode only) */
249
294
  timeBetween?: string
250
295
  }
@@ -409,6 +454,7 @@ export interface ArrayPredicateStepMeta {
409
454
  */
410
455
  export type WorkflowStepMeta =
411
456
  | RpcStepMeta
457
+ | ScenarioStepMeta
412
458
  | BranchStepMeta
413
459
  | ParallelGroupStepMeta
414
460
  | FanoutStepMeta
@@ -509,5 +555,43 @@ export interface PikkuScenarioWire extends PikkuWorkflowWire {
509
555
  options?: WorkflowExpectServiceOptions
510
556
  ) => Promise<void>
511
557
 
558
+ /**
559
+ * Run a registered scenario step. Shaped exactly like `do`'s RPC form —
560
+ * `(stepName, target, data, options)` — so the inspector reads the target as
561
+ * a string literal rather than resolving an imported symbol.
562
+ *
563
+ * The generated `TypedScenario` narrows these over `FlattenedScenarioStepMap`.
564
+ */
565
+ step(
566
+ stepName: string,
567
+ stepFunc: string,
568
+ data?: any,
569
+ options?: ScenarioStepOptions
570
+ ): Promise<any>
571
+
572
+ /** `step` with a "Given" prefix in the rendered prose */
573
+ given(
574
+ stepName: string,
575
+ stepFunc: string,
576
+ data?: any,
577
+ options?: ScenarioStepOptions
578
+ ): Promise<any>
579
+
580
+ /** `step` with a "When" prefix in the rendered prose */
581
+ when(
582
+ stepName: string,
583
+ stepFunc: string,
584
+ data?: any,
585
+ options?: ScenarioStepOptions
586
+ ): Promise<any>
587
+
588
+ /** `step` with a "Then" prefix in the rendered prose */
589
+ then(
590
+ stepName: string,
591
+ stepFunc: string,
592
+ data?: any,
593
+ options?: ScenarioStepOptions
594
+ ): Promise<any>
595
+
512
596
  runScheduledTask: (name: string) => Promise<unknown>
513
597
  }
@@ -0,0 +1,131 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { resolveFeatureScenarios } from './feature.js'
5
+ import type { CoreFeature, CoreWorkflow } from './workflow.types.js'
6
+
7
+ const scenario = (tags: string[] = []) =>
8
+ ({ tags, func: async () => ({}) }) as any
9
+
10
+ const registered = (entries: Array<[string, any]>): Map<string, CoreWorkflow> =>
11
+ new Map(entries.map(([name, func]) => [name, { name, func }]))
12
+
13
+ describe('resolveFeatureScenarios', () => {
14
+ test('resolves imported identifiers to the names they are registered under', () => {
15
+ const lazyLoad = scenario()
16
+ const roundTrip = scenario()
17
+ const features = new Map<string, CoreFeature>([
18
+ [
19
+ 'credentialFeature',
20
+ {
21
+ name: 'Credential API',
22
+ scenarios: [lazyLoad, roundTrip],
23
+ },
24
+ ],
25
+ ])
26
+
27
+ const { entries, unresolved } = resolveFeatureScenarios(
28
+ features,
29
+ registered([
30
+ ['credentialLazyLoadScenario', lazyLoad],
31
+ ['credentialRoundTripScenario', roundTrip],
32
+ ])
33
+ )
34
+
35
+ assert.deepEqual(unresolved, [])
36
+ assert.deepEqual(
37
+ entries.map((e) => e.scenarioName),
38
+ ['credentialLazyLoadScenario', 'credentialRoundTripScenario']
39
+ )
40
+ assert.equal(entries[0]!.featureName, 'Credential API')
41
+ assert.equal(entries[0]!.featureId, 'credentialFeature')
42
+ })
43
+
44
+ test('keeps declaration order, including repeats of the same scenario', () => {
45
+ const roundTrip = scenario()
46
+ const features = new Map<string, CoreFeature>([
47
+ [
48
+ 'credentialFeature',
49
+ {
50
+ name: 'Credential API',
51
+ scenarios: ['stripe', 'google', 'hmac-key'].map((name) => ({
52
+ scenario: roundTrip,
53
+ data: { name },
54
+ })),
55
+ },
56
+ ],
57
+ ])
58
+
59
+ const { entries } = resolveFeatureScenarios(
60
+ features,
61
+ registered([['credentialRoundTripScenario', roundTrip]])
62
+ )
63
+
64
+ assert.equal(entries.length, 3, 'a mapped loop is three separate runs')
65
+ assert.deepEqual(
66
+ entries.map((e) => e.data),
67
+ [{ name: 'stripe' }, { name: 'google' }, { name: 'hmac-key' }]
68
+ )
69
+ assert.ok(
70
+ entries.every((e) => e.scenarioName === 'credentialRoundTripScenario')
71
+ )
72
+ })
73
+
74
+ test('a bare reference carries no data', () => {
75
+ const lazyLoad = scenario()
76
+ const { entries } = resolveFeatureScenarios(
77
+ new Map([['f', { name: 'F', scenarios: [lazyLoad] } as CoreFeature]]),
78
+ registered([['lazyLoadScenario', lazyLoad]])
79
+ )
80
+ assert.equal(entries[0]!.data, undefined)
81
+ })
82
+
83
+ test("a scenario's effective tags union its own with the feature's", () => {
84
+ const lazyLoad = scenario(['scenario', 'credential'])
85
+ const { entries } = resolveFeatureScenarios(
86
+ new Map([
87
+ [
88
+ 'f',
89
+ {
90
+ name: 'F',
91
+ tags: ['nightly', 'credential'],
92
+ scenarios: [lazyLoad],
93
+ } as CoreFeature,
94
+ ],
95
+ ]),
96
+ registered([['lazyLoadScenario', lazyLoad]])
97
+ )
98
+ assert.deepEqual(entries[0]!.tags, ['scenario', 'credential', 'nightly'])
99
+ })
100
+
101
+ test('an unregistered scenario is reported, never silently matched by shape', () => {
102
+ const registeredScenario = scenario()
103
+ const lookalike = scenario()
104
+ const { entries, unresolved } = resolveFeatureScenarios(
105
+ new Map([
106
+ [
107
+ 'f',
108
+ {
109
+ name: 'F',
110
+ scenarios: [registeredScenario, lookalike],
111
+ } as CoreFeature,
112
+ ],
113
+ ]),
114
+ registered([['realScenario', registeredScenario]])
115
+ )
116
+ assert.deepEqual(
117
+ entries.map((e) => e.scenarioName),
118
+ ['realScenario']
119
+ )
120
+ assert.deepEqual(unresolved, [{ featureId: 'f', index: 1 }])
121
+ })
122
+
123
+ test('a feature with no scenarios contributes nothing', () => {
124
+ const { entries, unresolved } = resolveFeatureScenarios(
125
+ new Map([['f', { name: 'F', scenarios: [] } as CoreFeature]]),
126
+ registered([])
127
+ )
128
+ assert.deepEqual(entries, [])
129
+ assert.deepEqual(unresolved, [])
130
+ })
131
+ })