@loopstack/hitl-examples 0.1.1 → 0.2.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 (61) hide show
  1. package/README.md +38 -0
  2. package/dist/workflows/agent-ask-clarification/agent-ask-clarification-example.workflow.d.ts +1 -1
  3. package/dist/workflows/agent-ask-clarification/agent-ask-clarification-example.workflow.d.ts.map +1 -1
  4. package/dist/workflows/agent-ask-clarification/agent-ask-clarification-example.workflow.js +2 -2
  5. package/dist/workflows/agent-ask-clarification/agent-ask-clarification-example.workflow.js.map +1 -1
  6. package/dist/workflows/agent-ask-for-approval/agent-ask-for-approval-example.workflow.d.ts +1 -1
  7. package/dist/workflows/agent-ask-for-approval/agent-ask-for-approval-example.workflow.d.ts.map +1 -1
  8. package/dist/workflows/agent-ask-for-approval/agent-ask-for-approval-example.workflow.js +2 -2
  9. package/dist/workflows/agent-ask-for-approval/agent-ask-for-approval-example.workflow.js.map +1 -1
  10. package/dist/workflows/ask-user-confirm/ask-user-confirm-example.workflow.d.ts +1 -1
  11. package/dist/workflows/ask-user-confirm/ask-user-confirm-example.workflow.d.ts.map +1 -1
  12. package/dist/workflows/ask-user-confirm/ask-user-confirm-example.workflow.js +2 -2
  13. package/dist/workflows/ask-user-confirm/ask-user-confirm-example.workflow.js.map +1 -1
  14. package/dist/workflows/ask-user-options/ask-user-options-example.workflow.d.ts +1 -1
  15. package/dist/workflows/ask-user-options/ask-user-options-example.workflow.d.ts.map +1 -1
  16. package/dist/workflows/ask-user-options/ask-user-options-example.workflow.js +2 -2
  17. package/dist/workflows/ask-user-options/ask-user-options-example.workflow.js.map +1 -1
  18. package/dist/workflows/ask-user-text/ask-user-text-example.workflow.d.ts +1 -1
  19. package/dist/workflows/ask-user-text/ask-user-text-example.workflow.d.ts.map +1 -1
  20. package/dist/workflows/ask-user-text/ask-user-text-example.workflow.js +2 -2
  21. package/dist/workflows/ask-user-text/ask-user-text-example.workflow.js.map +1 -1
  22. package/dist/workflows/confirm-content/confirm-content-example.workflow.d.ts +1 -1
  23. package/dist/workflows/confirm-content/confirm-content-example.workflow.d.ts.map +1 -1
  24. package/dist/workflows/confirm-content/confirm-content-example.workflow.js +2 -2
  25. package/dist/workflows/confirm-content/confirm-content-example.workflow.js.map +1 -1
  26. package/dist/workflows/inline-form/feedback-form-document.d.ts +2 -0
  27. package/dist/workflows/inline-form/feedback-form-document.d.ts.map +1 -1
  28. package/dist/workflows/inline-form/feedback-form-document.js +2 -0
  29. package/dist/workflows/inline-form/feedback-form-document.js.map +1 -1
  30. package/dist/workflows/inline-form/feedback-form-document.yaml +3 -0
  31. package/dist/workflows/inline-form/inline-form-example.workflow.d.ts +1 -1
  32. package/dist/workflows/inline-form/inline-form-example.workflow.d.ts.map +1 -1
  33. package/dist/workflows/inline-form/inline-form-example.workflow.js +4 -4
  34. package/dist/workflows/inline-form/inline-form-example.workflow.js.map +1 -1
  35. package/dist/workflows/prompt-input-chat/prompt-input-chat-example.workflow.d.ts +5 -2
  36. package/dist/workflows/prompt-input-chat/prompt-input-chat-example.workflow.d.ts.map +1 -1
  37. package/dist/workflows/prompt-input-chat/prompt-input-chat-example.workflow.js +29 -16
  38. package/dist/workflows/prompt-input-chat/prompt-input-chat-example.workflow.js.map +1 -1
  39. package/package.json +3 -2
  40. package/src/workflows/agent-ask-clarification/__tests__/agent-ask-clarification-example.live.spec.ts +27 -0
  41. package/src/workflows/agent-ask-clarification/__tests__/agent-ask-clarification-example.spec.ts +86 -0
  42. package/src/workflows/agent-ask-clarification/agent-ask-clarification-example.workflow.ts +1 -1
  43. package/src/workflows/agent-ask-for-approval/__tests__/agent-ask-for-approval-example.live.spec.ts +27 -0
  44. package/src/workflows/agent-ask-for-approval/__tests__/agent-ask-for-approval-example.spec.ts +96 -0
  45. package/src/workflows/agent-ask-for-approval/agent-ask-for-approval-example.workflow.ts +1 -1
  46. package/src/workflows/ask-user-confirm/__tests__/ask-user-confirm-example.spec.ts +56 -0
  47. package/src/workflows/ask-user-confirm/ask-user-confirm-example.workflow.ts +1 -1
  48. package/src/workflows/ask-user-options/__tests__/ask-user-options-example.spec.ts +60 -0
  49. package/src/workflows/ask-user-options/ask-user-options-example.workflow.ts +1 -1
  50. package/src/workflows/ask-user-text/__tests__/ask-user-text-example.spec.ts +62 -0
  51. package/src/workflows/ask-user-text/ask-user-text-example.workflow.ts +1 -1
  52. package/src/workflows/confirm-content/__tests__/confirm-content-example.spec.ts +56 -0
  53. package/src/workflows/confirm-content/confirm-content-example.workflow.ts +1 -1
  54. package/src/workflows/inline-form/__tests__/inline-form-example.spec.ts +54 -0
  55. package/src/workflows/inline-form/feedback-form-document.ts +2 -0
  56. package/src/workflows/inline-form/feedback-form-document.yaml +3 -0
  57. package/src/workflows/inline-form/inline-form-example.workflow.ts +10 -3
  58. package/src/workflows/meeting-notes/__tests__/meeting-notes-example.live.spec.ts +38 -0
  59. package/src/workflows/meeting-notes/__tests__/meeting-notes-example.spec.ts +105 -0
  60. package/src/workflows/prompt-input-chat/__tests__/prompt-input-chat-example.spec.ts +83 -0
  61. package/src/workflows/prompt-input-chat/prompt-input-chat-example.workflow.ts +34 -12
@@ -0,0 +1,86 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { AgentModule } from '@loopstack/agent';
3
+ import { LlmGenerateTextTool, LlmProviderModule } from '@loopstack/llm-provider-module';
4
+ import { replay, runWorkflow } from '@loopstack/testing';
5
+ import { HitlExamplesModule } from '../../../hitl-examples.module';
6
+ import { AgentAskClarificationExampleWorkflow } from '../agent-ask-clarification-example.workflow';
7
+
8
+ /**
9
+ * A scripted agent LLM turn in the shape `llm_generate_text` returns — including the
10
+ * `documents` declaration the live tool emits, so replay materializes the assistant message
11
+ * as a conversation document exactly like a real call would.
12
+ */
13
+ const llmTurn = (message: { id: string; role: string; text: string; blocks: unknown[]; stopReason: string }) => ({
14
+ tool: 'llm_generate_text',
15
+ envelope: {
16
+ data: { message, response: {} },
17
+ metadata: { provider: 'claude', model: 'claude-sonnet-4-6' },
18
+ documents: [{ documentName: 'llm_message', content: message, options: { meta: { provider: 'claude' } } }],
19
+ },
20
+ });
21
+
22
+ const CLARIFICATION_TURN = llmTurn({
23
+ id: 'msg_1',
24
+ role: 'assistant',
25
+ text: '',
26
+ blocks: [
27
+ {
28
+ type: 'tool_call',
29
+ id: 'toolu_1',
30
+ name: 'ask_clarification',
31
+ args: { question: 'What is your budget, and do you prefer a warm or cold climate?' },
32
+ },
33
+ ],
34
+ stopReason: 'tool_use',
35
+ });
36
+
37
+ const RECOMMENDATION_TURN = llmTurn({
38
+ id: 'msg_2',
39
+ role: 'assistant',
40
+ text: 'With €2000 and a warm climate in mind, I recommend Lisbon, Portugal.',
41
+ blocks: [{ type: 'text', text: 'With €2000 and a warm climate in mind, I recommend Lisbon, Portugal.' }],
42
+ stopReason: 'end_turn',
43
+ });
44
+
45
+ describe('AgentAskClarificationExampleWorkflow', () => {
46
+ it('replays the LLM turns while the agent, clarification tool, and HITL child run live', async () => {
47
+ const run = await runWorkflow(AgentAskClarificationExampleWorkflow, undefined, {
48
+ imports: [LlmProviderModule, AgentModule, HitlExamplesModule],
49
+ replayTools: [LlmGenerateTextTool],
50
+ replay: replay({ version: 3, recordings: [CLARIFICATION_TURN, RECOMMENDATION_TURN] }),
51
+ answers: { userAnswered: { answer: 'Budget €2000, warm climate please' } },
52
+ });
53
+
54
+ expect(run.error).toBeUndefined();
55
+ expect(run.status).toBe('completed');
56
+ expect(run.path).toEqual(['start', 'agentComplete']);
57
+ expect(run.result).toEqual({ response: 'With €2000 and a warm climate in mind, I recommend Lisbon, Portugal.' });
58
+
59
+ // The agent child ran the loop to completion — assert its outcome, not its internals.
60
+ expect(run.children).toHaveLength(1);
61
+ const agent = run.children[0];
62
+ expect(agent.workflowName).toBe('agent');
63
+ expect(agent.status).toBe('completed');
64
+ expect(agent.result).toEqual({ response: 'With €2000 and a warm climate in mind, I recommend Lisbon, Portugal.' });
65
+ });
66
+
67
+ it('parks with the clarification question shown when no answer is scripted', async () => {
68
+ const run = await runWorkflow(AgentAskClarificationExampleWorkflow, undefined, {
69
+ imports: [LlmProviderModule, AgentModule, HitlExamplesModule],
70
+ replayTools: [LlmGenerateTextTool],
71
+ replay: replay({ version: 3, recordings: [CLARIFICATION_TURN] }),
72
+ });
73
+
74
+ expect(run.status).toBe('waiting');
75
+ // The clarification prompt lives on the AskUserWorkflow launched by the agent's tool call,
76
+ // several levels down. parkView() walks the tree and surfaces what the user would see —
77
+ // no manual traversal of the stateless carrier.
78
+ const view = run.parkView();
79
+ expect(view).toMatchObject({
80
+ workflowName: 'ask_user',
81
+ widget: 'text-prompt',
82
+ content: { question: expect.stringContaining('budget') },
83
+ defaultTransition: 'userAnswered',
84
+ });
85
+ });
86
+ });
@@ -21,7 +21,7 @@ export class AgentAskClarificationExampleWorkflow extends BaseWorkflow {
21
21
  }
22
22
 
23
23
  @Transition({ to: 'running' })
24
- async start(_state: Record<string, unknown>) {
24
+ async start() {
25
25
  await this.agentWorkflow.run(
26
26
  {
27
27
  system: SYSTEM_PROMPT,
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { AgentModule } from '@loopstack/agent';
3
+ import { LlmProviderModule } from '@loopstack/llm-provider-module';
4
+ import { runWorkflow } from '@loopstack/testing';
5
+ import { HitlExamplesModule } from '../../../hitl-examples.module';
6
+ import { AgentAskForApprovalExampleWorkflow } from '../agent-ask-for-approval-example.workflow';
7
+
8
+ /**
9
+ * Live-LLM check-up (`npm run test:live`, needs ANTHROPIC_API_KEY): the real model drafts
10
+ * release notes and requests approval; the scripted confirmation resumes it. Assertions are
11
+ * structural — completion and a non-empty markdown response.
12
+ */
13
+ describe('AgentAskForApprovalExampleWorkflow — live', () => {
14
+ it('drafts release notes, gets approval, and responds with the markdown', async () => {
15
+ const run = await runWorkflow(AgentAskForApprovalExampleWorkflow, undefined, {
16
+ imports: [LlmProviderModule, AgentModule, HitlExamplesModule],
17
+ answers: { userConfirmed: {} },
18
+ });
19
+
20
+ expect(run.error).toBeUndefined();
21
+ expect(run.status).toBe('completed');
22
+ expect(run.path).toEqual(['start', 'agentComplete']);
23
+
24
+ const result = run.result as { response: string };
25
+ expect(result.response.length).toBeGreaterThan(0);
26
+ });
27
+ });
@@ -0,0 +1,96 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { AgentModule } from '@loopstack/agent';
3
+ import { LlmGenerateTextTool, LlmProviderModule } from '@loopstack/llm-provider-module';
4
+ import { replay, runWorkflow } from '@loopstack/testing';
5
+ import { HitlExamplesModule } from '../../../hitl-examples.module';
6
+ import { AgentAskForApprovalExampleWorkflow } from '../agent-ask-for-approval-example.workflow';
7
+
8
+ const DRAFT = '## v1.2.3\n\n- Added webhook signature verification\n- Fixed a date-parsing bug in the importer';
9
+
10
+ /**
11
+ * A scripted agent LLM turn in the shape `llm_generate_text` returns — including the
12
+ * `documents` declaration the live tool emits, so replay materializes the assistant message
13
+ * as a conversation document exactly like a real call would.
14
+ */
15
+ const llmTurn = (message: { id: string; role: string; text: string; blocks: unknown[]; stopReason: string }) => ({
16
+ tool: 'llm_generate_text',
17
+ envelope: {
18
+ data: { message, response: {} },
19
+ metadata: { provider: 'claude', model: 'claude-sonnet-4-6' },
20
+ documents: [{ documentName: 'llm_message', content: message, options: { meta: { provider: 'claude' } } }],
21
+ },
22
+ });
23
+
24
+ const DRAFT_TURN = llmTurn({
25
+ id: 'msg_1',
26
+ role: 'assistant',
27
+ text: '',
28
+ blocks: [{ type: 'tool_call', id: 'toolu_1', name: 'ask_for_approval', args: { concept: DRAFT } }],
29
+ stopReason: 'tool_use',
30
+ });
31
+
32
+ const FINAL_TURN = llmTurn({
33
+ id: 'msg_2',
34
+ role: 'assistant',
35
+ text: DRAFT,
36
+ blocks: [{ type: 'text', text: DRAFT }],
37
+ stopReason: 'end_turn',
38
+ });
39
+
40
+ describe('AgentAskForApprovalExampleWorkflow', () => {
41
+ it('approves the drafted markdown and completes with it as the response', async () => {
42
+ const run = await runWorkflow(AgentAskForApprovalExampleWorkflow, undefined, {
43
+ imports: [LlmProviderModule, AgentModule, HitlExamplesModule],
44
+ replayTools: [LlmGenerateTextTool],
45
+ replay: replay({ version: 3, recordings: [DRAFT_TURN, FINAL_TURN] }),
46
+ answers: { userConfirmed: {} },
47
+ });
48
+
49
+ expect(run.error).toBeUndefined();
50
+ expect(run.status).toBe('completed');
51
+ expect(run.path).toEqual(['start', 'agentComplete']);
52
+ expect(run.result).toEqual({ response: DRAFT });
53
+ // The approved markdown was published as a document by the parent (an observable outcome).
54
+ expect(run.documents.some((d) => (d.content as { markdown?: string }).markdown === DRAFT)).toBe(true);
55
+ });
56
+
57
+ it('completes with the rejection response when the user denies', async () => {
58
+ const rejectionTurn = llmTurn({
59
+ id: 'msg_2',
60
+ role: 'assistant',
61
+ text: 'The draft was rejected by the user.',
62
+ blocks: [{ type: 'text', text: 'The draft was rejected by the user.' }],
63
+ stopReason: 'end_turn',
64
+ });
65
+
66
+ const run = await runWorkflow(AgentAskForApprovalExampleWorkflow, undefined, {
67
+ imports: [LlmProviderModule, AgentModule, HitlExamplesModule],
68
+ replayTools: [LlmGenerateTextTool],
69
+ replay: replay({ version: 3, recordings: [DRAFT_TURN, rejectionTurn] }),
70
+ answers: { userDenied: {} },
71
+ });
72
+
73
+ expect(run.error).toBeUndefined();
74
+ expect(run.status).toBe('completed');
75
+ expect(run.result).toEqual({ response: 'The draft was rejected by the user.' });
76
+ });
77
+
78
+ it('parks showing the approval form when no answer is scripted', async () => {
79
+ const run = await runWorkflow(AgentAskForApprovalExampleWorkflow, undefined, {
80
+ imports: [LlmProviderModule, AgentModule, HitlExamplesModule],
81
+ replayTools: [LlmGenerateTextTool],
82
+ replay: replay({ version: 3, recordings: [DRAFT_TURN] }),
83
+ });
84
+
85
+ expect(run.status).toBe('waiting');
86
+ // The agent's ask_for_approval tool launched a ConfirmUserWorkflow presenting the draft;
87
+ // parkView() surfaces the review form the user would act on.
88
+ const view = run.parkView();
89
+ expect(view).toMatchObject({
90
+ workflowName: 'confirm_user',
91
+ widget: 'form',
92
+ content: { markdown: DRAFT },
93
+ actions: ['Deny', 'Confirm'],
94
+ });
95
+ });
96
+ });
@@ -22,7 +22,7 @@ export class AgentAskForApprovalExampleWorkflow extends BaseWorkflow {
22
22
  }
23
23
 
24
24
  @Transition({ to: 'running' })
25
- async start(_state: Record<string, unknown>) {
25
+ async start() {
26
26
  await this.agentWorkflow.run(
27
27
  {
28
28
  system: SYSTEM_PROMPT,
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { HitlModule } from '@loopstack/hitl';
3
+ import { LlmProviderModule } from '@loopstack/llm-provider-module';
4
+ import { type TestRun, coverage, runWorkflow } from '@loopstack/testing';
5
+ import { HitlExamplesModule } from '../../../hitl-examples.module';
6
+ import { AskUserConfirmExampleWorkflow } from '../ask-user-confirm-example.workflow';
7
+
8
+ describe('AskUserConfirmExampleWorkflow', () => {
9
+ const runs: TestRun[] = [];
10
+ const run = async (answer?: 'yes' | 'no') => {
11
+ const result = await runWorkflow(AskUserConfirmExampleWorkflow, undefined, {
12
+ imports: [LlmProviderModule, HitlModule, HitlExamplesModule],
13
+ ...(answer ? { answers: { userAnswered: { answer } } } : {}),
14
+ });
15
+ runs.push(result);
16
+ return result;
17
+ };
18
+
19
+ it('confirms with "yes"', async () => {
20
+ const result = await run('yes');
21
+
22
+ expect(result.error).toBeUndefined();
23
+ expect(result.status).toBe('completed');
24
+ expect(result.result).toEqual({ sent: true });
25
+ });
26
+
27
+ it('declines with "no"', async () => {
28
+ const result = await run('no');
29
+
30
+ expect(result.status).toBe('completed');
31
+ expect(result.result).toEqual({ sent: false });
32
+ const texts = result.documents.map((d) => (d.content as { text?: string }).text ?? '');
33
+ expect(texts).toContain('Skipping — email was not sent.');
34
+ });
35
+
36
+ it('parks showing the yes/no prompt when no answer is scripted', async () => {
37
+ const result = await run();
38
+
39
+ expect(result.status).toBe('waiting');
40
+ // The prompt lives on the AskUserWorkflow child, three levels down — parkView() walks
41
+ // the tree and resolves the same widget the CLI and Studio render.
42
+ const view = result.parkView();
43
+ expect(view).toMatchObject({
44
+ workflowName: 'ask_user',
45
+ widget: 'confirm-prompt',
46
+ content: { question: 'Send the email now?' },
47
+ defaultTransition: 'userAnswered',
48
+ });
49
+ });
50
+
51
+ it('covers every transition and park (coverage gate)', () => {
52
+ const cov = coverage(runs, AskUserConfirmExampleWorkflow);
53
+ expect(cov.missingTransitions).toEqual([]);
54
+ expect(cov.missingParks).toEqual([]);
55
+ });
56
+ });
@@ -16,7 +16,7 @@ export class AskUserConfirmExampleWorkflow extends BaseWorkflow {
16
16
  }
17
17
 
18
18
  @Transition({ to: 'waiting_for_yes_no' })
19
- async askToSend(_state: Record<string, unknown>) {
19
+ async askToSend() {
20
20
  await this.askUserWorkflow.run(
21
21
  { question: 'Send the email now?', mode: 'confirm' },
22
22
  { callback: { transition: 'decisionReceived' }, show: 'inline', label: 'Waiting for yes/no...' },
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { HitlModule } from '@loopstack/hitl';
3
+ import { LlmProviderModule } from '@loopstack/llm-provider-module';
4
+ import { type TestRun, coverage, runWorkflow } from '@loopstack/testing';
5
+ import { HitlExamplesModule } from '../../../hitl-examples.module';
6
+ import { AskUserOptionsExampleWorkflow } from '../ask-user-options-example.workflow';
7
+
8
+ describe('AskUserOptionsExampleWorkflow', () => {
9
+ const runs: TestRun[] = [];
10
+ const run = async (answer?: string) => {
11
+ const result = await runWorkflow(AskUserOptionsExampleWorkflow, undefined, {
12
+ imports: [LlmProviderModule, HitlModule, HitlExamplesModule],
13
+ ...(answer ? { answers: { userAnswered: { answer } } } : {}),
14
+ });
15
+ runs.push(result);
16
+ return result;
17
+ };
18
+
19
+ it('picks a listed option', async () => {
20
+ const result = await run('staging');
21
+
22
+ expect(result.error).toBeUndefined();
23
+ expect(result.status).toBe('completed');
24
+ expect(result.result).toEqual({ environment: 'staging', custom: false });
25
+ });
26
+
27
+ it('accepts a custom answer outside the option list', async () => {
28
+ const result = await run('local-docker');
29
+
30
+ expect(result.status).toBe('completed');
31
+ expect(result.result).toEqual({ environment: 'local-docker', custom: true });
32
+ const texts = result.documents.map((d) => (d.content as { text?: string }).text ?? '');
33
+ expect(texts).toContain('Custom environment selected: local-docker');
34
+ });
35
+
36
+ it('parks showing the choices when no answer is scripted', async () => {
37
+ const result = await run();
38
+
39
+ expect(result.status).toBe('waiting');
40
+ // The options list and custom-answer affordance the user would see, resolved from the
41
+ // AskUserWorkflow child by the same canonical rules the CLI and Studio use.
42
+ const view = result.parkView();
43
+ expect(view).toMatchObject({
44
+ workflowName: 'ask_user',
45
+ widget: 'choices',
46
+ content: {
47
+ question: 'Which environment should we deploy to?',
48
+ options: ['staging', 'production'],
49
+ allowCustomAnswer: true,
50
+ },
51
+ defaultTransition: 'userAnswered',
52
+ });
53
+ });
54
+
55
+ it('covers every transition and park (coverage gate)', () => {
56
+ const cov = coverage(runs, AskUserOptionsExampleWorkflow);
57
+ expect(cov.missingTransitions).toEqual([]);
58
+ expect(cov.missingParks).toEqual([]);
59
+ });
60
+ });
@@ -18,7 +18,7 @@ export class AskUserOptionsExampleWorkflow extends BaseWorkflow {
18
18
  }
19
19
 
20
20
  @Transition({ to: 'waiting_for_choice' })
21
- async askEnvironment(_state: Record<string, unknown>) {
21
+ async askEnvironment() {
22
22
  await this.askUserWorkflow.run(
23
23
  {
24
24
  question: 'Which environment should we deploy to?',
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { HitlModule } from '@loopstack/hitl';
3
+ import { LlmProviderModule } from '@loopstack/llm-provider-module';
4
+ import { type TestRun, coverage, runWorkflow } from '@loopstack/testing';
5
+ import { HitlExamplesModule } from '../../../hitl-examples.module';
6
+ import { AskUserTextExampleWorkflow } from '../ask-user-text-example.workflow';
7
+
8
+ describe('AskUserTextExampleWorkflow', () => {
9
+ const runs: TestRun[] = [];
10
+
11
+ it('answers the inline AskUserWorkflow child and completes', async () => {
12
+ const run = await runWorkflow(AskUserTextExampleWorkflow, undefined, {
13
+ imports: [LlmProviderModule, HitlModule, HitlExamplesModule],
14
+ answers: { userAnswered: { answer: 'Ada' } },
15
+ });
16
+ runs.push(run);
17
+
18
+ expect(run.error).toBeUndefined();
19
+ expect(run.status).toBe('completed');
20
+ expect(run.path).toEqual(['askName', 'answerReceived']);
21
+ expect(run.result).toEqual({ name: 'Ada' });
22
+
23
+ expect(run.children).toHaveLength(1);
24
+ expect(run.children[0].workflowName).toBe('ask_user');
25
+ expect(run.children[0].status).toBe('completed');
26
+
27
+ const texts = run.documents.map((d) => (d.content as { text?: string }).text ?? '');
28
+ expect(texts).toContain('Hello, Ada!');
29
+
30
+ // A completed run shows the user nothing to answer.
31
+ expect(run.parkView()).toBeUndefined();
32
+ });
33
+
34
+ it('parks with the question shown when no answer is scripted', async () => {
35
+ const run = await runWorkflow(AskUserTextExampleWorkflow, undefined, {
36
+ imports: [LlmProviderModule, HitlModule, HitlExamplesModule],
37
+ });
38
+ runs.push(run);
39
+
40
+ expect(run.status).toBe('waiting');
41
+ expect(run.children[0].status).toBe('waiting');
42
+
43
+ // What the user would actually see: the text prompt of the ask_user sub-workflow —
44
+ // resolved by the same canonical rules the CLI and Studio use.
45
+ const view = run.parkView();
46
+ expect(view).toMatchObject({
47
+ workflowId: run.children[0].workflowId,
48
+ workflowName: 'ask_user',
49
+ widget: 'text-prompt',
50
+ documentName: 'ask_user',
51
+ content: { question: 'What is your name?' },
52
+ transitions: ['userAnswered'],
53
+ defaultTransition: 'userAnswered',
54
+ });
55
+ });
56
+
57
+ it('covers every transition and park (coverage gate)', () => {
58
+ const cov = coverage(runs, AskUserTextExampleWorkflow);
59
+ expect(cov.missingTransitions).toEqual([]);
60
+ expect(cov.missingParks).toEqual([]);
61
+ });
62
+ });
@@ -16,7 +16,7 @@ export class AskUserTextExampleWorkflow extends BaseWorkflow {
16
16
  }
17
17
 
18
18
  @Transition({ to: 'waiting_for_answer' })
19
- async askName(_state: Record<string, unknown>) {
19
+ async askName() {
20
20
  await this.askUserWorkflow.run(
21
21
  { question: 'What is your name?' },
22
22
  { callback: { transition: 'answerReceived' }, show: 'inline', label: 'Waiting for answer...' },
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { HitlModule } from '@loopstack/hitl';
3
+ import { LlmProviderModule } from '@loopstack/llm-provider-module';
4
+ import { type TestRun, coverage, runWorkflow } from '@loopstack/testing';
5
+ import { HitlExamplesModule } from '../../../hitl-examples.module';
6
+ import { ConfirmContentExampleWorkflow } from '../confirm-content-example.workflow';
7
+
8
+ describe('ConfirmContentExampleWorkflow', () => {
9
+ const runs: TestRun[] = [];
10
+ const run = async (decision?: 'userConfirmed' | 'userDenied') => {
11
+ const result = await runWorkflow(ConfirmContentExampleWorkflow, undefined, {
12
+ imports: [LlmProviderModule, HitlModule, HitlExamplesModule],
13
+ ...(decision ? { answers: { [decision]: {} } } : {}),
14
+ });
15
+ runs.push(result);
16
+ return result;
17
+ };
18
+
19
+ it('user confirms the summary', async () => {
20
+ const result = await run('userConfirmed');
21
+
22
+ expect(result.error).toBeUndefined();
23
+ expect(result.status).toBe('completed');
24
+ expect(result.result).toEqual({ confirmed: true });
25
+ });
26
+
27
+ it('user denies the summary', async () => {
28
+ const result = await run('userDenied');
29
+
30
+ expect(result.status).toBe('completed');
31
+ expect(result.result).toEqual({ confirmed: false });
32
+ const texts = result.documents.map((d) => (d.content as { text?: string }).text ?? '');
33
+ expect(texts).toContain('User denied — aborting deploy.');
34
+ });
35
+
36
+ it('parks showing the review form with the deploy summary', async () => {
37
+ const result = await run();
38
+
39
+ expect(result.status).toBe('waiting');
40
+ // The whole point of this example: show the user a markdown blob and wait. parkView()
41
+ // asserts they would see the review form (with its Deny/Confirm actions) carrying the summary.
42
+ const view = result.parkView();
43
+ expect(view).toMatchObject({
44
+ workflowName: 'confirm_user',
45
+ widget: 'form',
46
+ content: { markdown: expect.stringContaining('Ready to deploy?') },
47
+ actions: ['Deny', 'Confirm'],
48
+ });
49
+ });
50
+
51
+ it('covers every transition and park (coverage gate)', () => {
52
+ const cov = coverage(runs, ConfirmContentExampleWorkflow);
53
+ expect(cov.missingTransitions).toEqual([]);
54
+ expect(cov.missingParks).toEqual([]);
55
+ });
56
+ });
@@ -26,7 +26,7 @@ export class ConfirmContentExampleWorkflow extends BaseWorkflow {
26
26
  }
27
27
 
28
28
  @Transition({ to: 'waiting_for_confirmation' })
29
- async showSummary(_state: Record<string, unknown>) {
29
+ async showSummary() {
30
30
  await this.confirmUserWorkflow.run(
31
31
  { markdown: DEPLOY_SUMMARY },
32
32
  { callback: { transition: 'decisionReceived' }, show: 'inline', label: 'Waiting for confirmation...' },
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { LlmProviderModule } from '@loopstack/llm-provider-module';
3
+ import { type TestRun, coverage, runWorkflow } from '@loopstack/testing';
4
+ import { HitlExamplesModule } from '../../../hitl-examples.module';
5
+ import { InlineFormExampleWorkflow } from '../inline-form-example.workflow';
6
+
7
+ describe('InlineFormExampleWorkflow', () => {
8
+ const runs: TestRun[] = [];
9
+
10
+ it('submits the form via a scripted answer and completes', async () => {
11
+ const feedback = { subject: 'Loopstack HITL forms', rating: 5, comment: 'Great!' };
12
+
13
+ const run = await runWorkflow(InlineFormExampleWorkflow, undefined, {
14
+ imports: [LlmProviderModule, HitlExamplesModule],
15
+ answers: { submitFeedback: feedback },
16
+ });
17
+ runs.push(run);
18
+
19
+ expect(run.error).toBeUndefined();
20
+ expect(run.status).toBe('completed');
21
+ expect(run.path).toEqual(['showForm', 'submitFeedback']);
22
+ expect(run.result).toEqual({ feedback });
23
+ expect(run.document('feedback')).toEqual(feedback);
24
+ });
25
+
26
+ it('parks on the pre-filled form when no answer is scripted', async () => {
27
+ const run = await runWorkflow(InlineFormExampleWorkflow, undefined, {
28
+ imports: [LlmProviderModule, HitlExamplesModule],
29
+ });
30
+ runs.push(run);
31
+
32
+ expect(run.status).toBe('waiting');
33
+ expect(run.place).toBe('waiting_for_feedback');
34
+
35
+ // The custom form widget the user would fill in — its Submit action binds to the
36
+ // workflow's own wait transition, pre-filled with the workflow-provided subject.
37
+ // (The `subject` field's `readonly` rule is enforced by the form widget / API layer,
38
+ // not the state machine, so it isn't exercised here.)
39
+ const view = run.parkView();
40
+ expect(view).toMatchObject({
41
+ widget: 'form',
42
+ documentName: 'feedback_form',
43
+ content: { subject: 'Loopstack HITL forms', rating: 3 },
44
+ actions: ['Submit Feedback'],
45
+ defaultTransition: 'submitFeedback',
46
+ });
47
+ });
48
+
49
+ it('covers every transition and park (coverage gate)', () => {
50
+ const cov = coverage(runs, InlineFormExampleWorkflow);
51
+ expect(cov.missingTransitions).toEqual([]);
52
+ expect(cov.missingParks).toEqual([]);
53
+ });
54
+ });
@@ -2,6 +2,7 @@ import { z } from 'zod';
2
2
  import { Document } from '@loopstack/common';
3
3
 
4
4
  export const FeedbackFormDocumentSchema = z.object({
5
+ subject: z.string(),
5
6
  rating: z.number().min(1).max(5),
6
7
  comment: z.string(),
7
8
  });
@@ -11,6 +12,7 @@ export const FeedbackFormDocumentSchema = z.object({
11
12
  widget: './feedback-form-document.yaml',
12
13
  })
13
14
  export class FeedbackFormDocument {
15
+ subject: string;
14
16
  rating: number;
15
17
  comment: string;
16
18
  }
@@ -1,6 +1,9 @@
1
1
  widget: form
2
2
  options:
3
3
  properties:
4
+ subject:
5
+ title: Subject
6
+ readonly: true
4
7
  rating:
5
8
  title: Rating (1–5)
6
9
  widget: number
@@ -13,8 +13,15 @@ type FeedbackPayload = z.infer<typeof FeedbackFormDocumentSchema>;
13
13
  })
14
14
  export class InlineFormExampleWorkflow extends BaseWorkflow {
15
15
  @Transition({ to: 'waiting_for_feedback' })
16
- async showForm(_state: Record<string, unknown>) {
17
- await this.documentStore.save(FeedbackFormDocument, { rating: 3, comment: '' }, { key: 'feedback' });
16
+ async showForm() {
17
+ // `subject` is workflow-provided and marked `readonly: true` in the
18
+ // widget config — rendered non-editable, and the backend rejects
19
+ // submissions that change it.
20
+ await this.documentStore.save(
21
+ FeedbackFormDocument,
22
+ { subject: 'Loopstack HITL forms', rating: 3, comment: '' },
23
+ { key: 'feedback' },
24
+ );
18
25
  }
19
26
 
20
27
  @Transition({
@@ -29,7 +36,7 @@ export class InlineFormExampleWorkflow extends BaseWorkflow {
29
36
 
30
37
  await this.documentStore.save(MessageDocument, {
31
38
  role: 'assistant',
32
- text: `Thanks for your feedback! Rating: ${payload.rating}/5. Comment: ${payload.comment}`,
39
+ text: `Thanks for your feedback on "${payload.subject}"! Rating: ${payload.rating}/5. Comment: ${payload.comment}`,
33
40
  });
34
41
 
35
42
  this.setResult({ feedback: payload } as unknown as Record<string, unknown>);
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { LlmProviderModule } from '@loopstack/llm-provider-module';
3
+ import { runWorkflow } from '@loopstack/testing';
4
+ import { HitlExamplesModule } from '../../../hitl-examples.module';
5
+ import { MeetingNotesExampleWorkflow } from '../meeting-notes-example.workflow';
6
+
7
+ /**
8
+ * Live-LLM check-up (`npm run test:live`, needs ANTHROPIC_API_KEY): the extraction step runs
9
+ * against the real model. Assertions are structural — path taken, schema shape — never exact
10
+ * content.
11
+ */
12
+ describe('MeetingNotesExampleWorkflow — live', () => {
13
+ it('extracts structured notes from the real model', async () => {
14
+ const confirmSubmission = {
15
+ date: '2025-01-01',
16
+ summary: 'Confirmed as submitted.',
17
+ participants: ['Sarah'],
18
+ decisions: ['Cut costs'],
19
+ actionItems: ['Anna follows up on vendor pricing'],
20
+ };
21
+
22
+ const run = await runWorkflow(MeetingNotesExampleWorkflow, undefined, {
23
+ imports: [LlmProviderModule, HitlExamplesModule],
24
+ answers: {
25
+ userResponse: { text: 'Budget meeting: Sarah said cut costs. Anna follows up on vendor pricing.' },
26
+ confirm: confirmSubmission,
27
+ },
28
+ });
29
+
30
+ expect(run.error).toBeUndefined();
31
+ expect(run.status).toBe('completed');
32
+ expect(run.path).toEqual(['createForm', 'userResponse', 'optimizeNotes', 'confirm']);
33
+
34
+ // The live extraction produced a schema-valid document before the user confirmed
35
+ const optimizedDocs = run.documents.filter((d) => Array.isArray((d.content as { decisions?: unknown }).decisions));
36
+ expect(optimizedDocs.length).toBeGreaterThan(0);
37
+ });
38
+ });