@loopstack/testing-examples 0.1.0 → 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 (36) hide show
  1. package/README.md +31 -8
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +1 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/testing-examples.module.d.ts.map +1 -1
  7. package/dist/testing-examples.module.js +6 -2
  8. package/dist/testing-examples.module.js.map +1 -1
  9. package/dist/triage/approval-prompt.document.d.ts +16 -0
  10. package/dist/triage/approval-prompt.document.d.ts.map +1 -0
  11. package/dist/triage/approval-prompt.document.js +31 -0
  12. package/dist/triage/approval-prompt.document.js.map +1 -0
  13. package/dist/triage/classify-ticket.tool.d.ts +22 -0
  14. package/dist/triage/classify-ticket.tool.d.ts.map +1 -0
  15. package/dist/triage/classify-ticket.tool.js +42 -0
  16. package/dist/triage/classify-ticket.tool.js.map +1 -0
  17. package/dist/triage/index.d.ts +4 -0
  18. package/dist/triage/index.d.ts.map +1 -0
  19. package/dist/triage/index.js +20 -0
  20. package/dist/triage/index.js.map +1 -0
  21. package/dist/triage/triage-ticket.workflow.d.ts +23 -0
  22. package/dist/triage/triage-ticket.workflow.d.ts.map +1 -0
  23. package/dist/triage/triage-ticket.workflow.js +70 -0
  24. package/dist/triage/triage-ticket.workflow.js.map +1 -0
  25. package/package.json +5 -3
  26. package/src/index.ts +1 -0
  27. package/src/testing-examples.module.ts +6 -2
  28. package/src/triage/__tests__/__recordings__/triage.json +17 -0
  29. package/src/triage/__tests__/classify-ticket.tool.spec.ts +36 -0
  30. package/src/triage/__tests__/triage-ticket-contract-fake.spec.ts +38 -0
  31. package/src/triage/__tests__/triage-ticket-replay.spec.ts +62 -0
  32. package/src/triage/__tests__/triage-ticket.workflow.spec.ts +80 -0
  33. package/src/triage/approval-prompt.document.ts +28 -0
  34. package/src/triage/classify-ticket.tool.ts +40 -0
  35. package/src/triage/index.ts +3 -0
  36. package/src/triage/triage-ticket.workflow.ts +58 -0
package/README.md CHANGED
@@ -1,19 +1,42 @@
1
1
  ---
2
- title: Testing Examples (placeholder)
3
- description: Placeholder for workflow testing examples in Loopstack — @loopstack/testing fixtures, transition assertions, mocked LLM responses (coming soon)
2
+ title: Testing Examples
3
+ description: Runnable workflow-testing examples for Loopstack — runWorkflow() in-process workflow tests with scripted HITL answers, parkView() park assertions, coverage() gates, testTool() tool unit tests, fixture auto record/replay, and createContractFake() DI fakes. Companion package to the Testing guide.
4
4
  ---
5
5
 
6
6
  # @loopstack/testing-examples
7
7
 
8
- > Workflow testing examples for the [Loopstack](https://loopstack.ai) automation framework. **Placeholder examples coming soon.**
8
+ > Runnable testing examples for the [Loopstack](https://loopstack.ai) automation framework — the patterns from the [Testing guide](https://loopstack.ai/docs/build/testing) and [Testing Methodology](https://loopstack.ai/docs/build/testing-methodology) as working code.
9
9
 
10
- This module is reserved for future workflow-testing examples:
10
+ The example under test is a small deterministic triage flow (`src/triage/`): a `classify_ticket` tool grades a support ticket, the workflow presents the severity as a yes/no approval prompt, then waits for the human decision. No LLM — the whole flow is deterministic, so every test is exact.
11
11
 
12
- - Using `@loopstack/testing` fixtures
13
- - Asserting on transitions and final state
14
- - Mocking LLM responses for deterministic tests
12
+ Scenarios are named for the acceptance criterion they check (`it('C1: …')`), so the test report reads as a criteria matrix — the discipline from the methodology guide.
15
13
 
16
- Track progress in the [Loopstack GitHub repo](https://github.com/loopstack-ai/loopstack/issues).
14
+ ## What each spec demonstrates
15
+
16
+ | Spec | Pattern |
17
+ | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
18
+ | `classify-ticket.tool.spec.ts` | **Tool unit test** — `testTool().forTool(...)`, assert on the envelope data (the `resultSchema` is validated by `call()`) |
19
+ | `triage-ticket.workflow.spec.ts` | **In-process workflow test** — `runWorkflow()` with scripted `answers`; outcome assertions on `status`/`path`/`result`/`document()`; `parkView()` for the human touchpoint; a `coverage()` gate closing the suite |
20
+ | `triage-ticket-replay.spec.ts` | **Record/replay** — the `fixture:` option (auto record/replay, CI-guarded) as the default, plus an inline `replay()` fixture for fully-scripted determinism |
21
+ | `triage-ticket-contract-fake.spec.ts` | **Contract fake** — `createContractFake()` substitutes the tool with a `resultSchema`-validated response and asserts how the workflow called it |
22
+
23
+ Run them:
24
+
25
+ ```bash
26
+ npm test
27
+ ```
28
+
29
+ ## Recording fixtures from live runs
30
+
31
+ The `fixture:` option records a fresh fixture when the file is missing and replays it when present. Record one from a real run:
32
+
33
+ ```bash
34
+ npm run start:dev # start the backend
35
+ loopstack run triage_ticket --arg text="Production is down!" --trace
36
+ loopstack runs <run-id> --record src/triage/__tests__/__recordings__/triage.json
37
+ ```
38
+
39
+ Replay is transition-scoped: recordings are matched by the transition they were captured in, then consumed in sequence. Each entry's metadata (`tool`, `workflow`, `transition`, `args`, and `config` when present) is **asserted** against the actual call — when the outgoing args or config drift from the recording the run **fails** with a drift error naming the tool and both values, the signal to re-validate live and re-record. In CI a missing fixture is an error (not a silent live run) unless `LOOPSTACK_RECORD=1` is set.
17
40
 
18
41
  ## About
19
42
 
package/dist/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export * from './testing-examples.module';
2
+ export * from './triage';
2
3
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,2BAA2B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,2BAA2B,CAAC;AAC1C,cAAc,UAAU,CAAC"}
package/dist/index.js CHANGED
@@ -15,4 +15,5 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./testing-examples.module"), exports);
18
+ __exportStar(require("./triage"), exports);
18
19
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,4DAA0C"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,4DAA0C;AAC1C,2CAAyB"}
@@ -1 +1 @@
1
- {"version":3,"file":"testing-examples.module.d.ts","sourceRoot":"","sources":["../src/testing-examples.module.ts"],"names":[],"mappings":"AAGA,qBAKa,qBAAqB;CAAG"}
1
+ {"version":3,"file":"testing-examples.module.d.ts","sourceRoot":"","sources":["../src/testing-examples.module.ts"],"names":[],"mappings":"AAKA,qBAOa,qBAAqB;CAAG"}
@@ -9,14 +9,18 @@ Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.TestingExamplesModule = void 0;
10
10
  const common_1 = require("@nestjs/common");
11
11
  const common_2 = require("@loopstack/common");
12
+ const classify_ticket_tool_1 = require("./triage/classify-ticket.tool");
13
+ const triage_ticket_workflow_1 = require("./triage/triage-ticket.workflow");
12
14
  let TestingExamplesModule = class TestingExamplesModule {
13
15
  };
14
16
  exports.TestingExamplesModule = TestingExamplesModule;
15
17
  exports.TestingExamplesModule = TestingExamplesModule = __decorate([
16
18
  (0, common_2.StudioApp)({
17
19
  title: 'Testing Examples',
18
- workflows: [],
20
+ workflows: [triage_ticket_workflow_1.TriageTicketWorkflow],
19
21
  }),
20
- (0, common_1.Module)({})
22
+ (0, common_1.Module)({
23
+ providers: [triage_ticket_workflow_1.TriageTicketWorkflow, classify_ticket_tool_1.ClassifyTicketTool],
24
+ })
21
25
  ], TestingExamplesModule);
22
26
  //# sourceMappingURL=testing-examples.module.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"testing-examples.module.js","sourceRoot":"","sources":["../src/testing-examples.module.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAAwC;AACxC,8CAA8C;AAOvC,IAAM,qBAAqB,GAA3B,MAAM,qBAAqB;CAAG,CAAA;AAAxB,sDAAqB;gCAArB,qBAAqB;IALjC,IAAA,kBAAS,EAAC;QACT,KAAK,EAAE,kBAAkB;QACzB,SAAS,EAAE,EAAE;KACd,CAAC;IACD,IAAA,eAAM,EAAC,EAAE,CAAC;GACE,qBAAqB,CAAG"}
1
+ {"version":3,"file":"testing-examples.module.js","sourceRoot":"","sources":["../src/testing-examples.module.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAAwC;AACxC,8CAA8C;AAC9C,wEAAmE;AACnE,4EAAuE;AAShE,IAAM,qBAAqB,GAA3B,MAAM,qBAAqB;CAAG,CAAA;AAAxB,sDAAqB;gCAArB,qBAAqB;IAPjC,IAAA,kBAAS,EAAC;QACT,KAAK,EAAE,kBAAkB;QACzB,SAAS,EAAE,CAAC,6CAAoB,CAAC;KAClC,CAAC;IACD,IAAA,eAAM,EAAC;QACN,SAAS,EAAE,CAAC,6CAAoB,EAAE,yCAAkB,CAAC;KACtD,CAAC;GACW,qBAAqB,CAAG"}
@@ -0,0 +1,16 @@
1
+ import { z } from 'zod';
2
+ export declare const ApprovalPromptSchema: z.ZodObject<{
3
+ question: z.ZodString;
4
+ severity: z.ZodEnum<{
5
+ high: "high";
6
+ normal: "normal";
7
+ }>;
8
+ answer: z.ZodOptional<z.ZodString>;
9
+ }, z.core.$strict>;
10
+ export type ApprovalPromptType = z.infer<typeof ApprovalPromptSchema>;
11
+ export declare class ApprovalPromptDocument {
12
+ question: string;
13
+ severity: 'high' | 'normal';
14
+ answer?: string;
15
+ }
16
+ //# sourceMappingURL=approval-prompt.document.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"approval-prompt.document.d.ts","sourceRoot":"","sources":["../../src/triage/approval-prompt.document.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,eAAO,MAAM,oBAAoB;;;;;;;kBAMtB,CAAC;AAEZ,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAQtE,qBAIa,sBAAsB;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,GAAG,QAAQ,CAAC;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB"}
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.ApprovalPromptDocument = exports.ApprovalPromptSchema = void 0;
10
+ const zod_1 = require("zod");
11
+ const common_1 = require("@loopstack/common");
12
+ exports.ApprovalPromptSchema = zod_1.z
13
+ .object({
14
+ question: zod_1.z.string(),
15
+ severity: zod_1.z.enum(['high', 'normal']),
16
+ answer: zod_1.z.string().optional(),
17
+ })
18
+ .strict();
19
+ let ApprovalPromptDocument = class ApprovalPromptDocument {
20
+ question;
21
+ severity;
22
+ answer;
23
+ };
24
+ exports.ApprovalPromptDocument = ApprovalPromptDocument;
25
+ exports.ApprovalPromptDocument = ApprovalPromptDocument = __decorate([
26
+ (0, common_1.Document)({
27
+ schema: exports.ApprovalPromptSchema,
28
+ widget: { widget: 'confirm-prompt', options: { transition: 'approve' } },
29
+ })
30
+ ], ApprovalPromptDocument);
31
+ //# sourceMappingURL=approval-prompt.document.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"approval-prompt.document.js","sourceRoot":"","sources":["../../src/triage/approval-prompt.document.ts"],"names":[],"mappings":";;;;;;;;;AAAA,6BAAwB;AACxB,8CAA6C;AAEhC,QAAA,oBAAoB,GAAG,OAAC;KAClC,MAAM,CAAC;IACN,QAAQ,EAAE,OAAC,CAAC,MAAM,EAAE;IACpB,QAAQ,EAAE,OAAC,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;IACpC,MAAM,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;CAC9B,CAAC;KACD,MAAM,EAAE,CAAC;AAcL,IAAM,sBAAsB,GAA5B,MAAM,sBAAsB;IACjC,QAAQ,CAAS;IACjB,QAAQ,CAAoB;IAC5B,MAAM,CAAU;CACjB,CAAA;AAJY,wDAAsB;iCAAtB,sBAAsB;IAJlC,IAAA,iBAAQ,EAAC;QACR,MAAM,EAAE,4BAAoB;QAC5B,MAAM,EAAE,EAAE,MAAM,EAAE,gBAAgB,EAAE,OAAO,EAAE,EAAE,UAAU,EAAE,SAAS,EAAE,EAAE;KACzE,CAAC;GACW,sBAAsB,CAIlC"}
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod';
2
+ import { BaseTool, ToolEnvelope } from '@loopstack/common';
3
+ declare const ClassifyTicketSchema: z.ZodObject<{
4
+ text: z.ZodString;
5
+ }, z.core.$strict>;
6
+ type ClassifyTicketArgs = z.infer<typeof ClassifyTicketSchema>;
7
+ export interface ClassificationResult {
8
+ severity: 'high' | 'normal';
9
+ reason: string;
10
+ }
11
+ export declare const ClassificationResultSchema: z.ZodObject<{
12
+ severity: z.ZodEnum<{
13
+ high: "high";
14
+ normal: "normal";
15
+ }>;
16
+ reason: z.ZodString;
17
+ }, z.core.$strict>;
18
+ export declare class ClassifyTicketTool extends BaseTool<ClassifyTicketArgs, object, ClassificationResult> {
19
+ protected handle(args: ClassifyTicketArgs): Promise<ToolEnvelope<ClassificationResult>>;
20
+ }
21
+ export {};
22
+ //# sourceMappingURL=classify-ticket.tool.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"classify-ticket.tool.d.ts","sourceRoot":"","sources":["../../src/triage/classify-ticket.tool.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,QAAQ,EAAQ,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAEjE,QAAA,MAAM,oBAAoB;;kBAIf,CAAC;AAEZ,KAAK,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAE/D,MAAM,WAAW,oBAAoB;IACnC,QAAQ,EAAE,MAAM,GAAG,QAAQ,CAAC;IAC5B,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,eAAO,MAAM,0BAA0B;;;;;;kBAGrC,CAAC;AAIH,qBAOa,kBAAmB,SAAQ,QAAQ,CAAC,kBAAkB,EAAE,MAAM,EAAE,oBAAoB,CAAC;cAChF,MAAM,CAAC,IAAI,EAAE,kBAAkB,GAAG,OAAO,CAAC,YAAY,CAAC,oBAAoB,CAAC,CAAC;CAQ9F"}
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.ClassifyTicketTool = exports.ClassificationResultSchema = void 0;
10
+ const zod_1 = require("zod");
11
+ const common_1 = require("@loopstack/common");
12
+ const ClassifyTicketSchema = zod_1.z
13
+ .object({
14
+ text: zod_1.z.string(),
15
+ })
16
+ .strict();
17
+ exports.ClassificationResultSchema = zod_1.z.strictObject({
18
+ severity: zod_1.z.enum(['high', 'normal']),
19
+ reason: zod_1.z.string(),
20
+ });
21
+ const URGENCY_KEYWORDS = ['down', 'outage', 'urgent', 'critical'];
22
+ let ClassifyTicketTool = class ClassifyTicketTool extends common_1.BaseTool {
23
+ async handle(args) {
24
+ const matched = URGENCY_KEYWORDS.find((word) => args.text.toLowerCase().includes(word));
25
+ return Promise.resolve({
26
+ data: matched
27
+ ? { severity: 'high', reason: `matched urgency keyword "${matched}"` }
28
+ : { severity: 'normal', reason: 'no urgency keywords' },
29
+ });
30
+ }
31
+ };
32
+ exports.ClassifyTicketTool = ClassifyTicketTool;
33
+ exports.ClassifyTicketTool = ClassifyTicketTool = __decorate([
34
+ (0, common_1.Tool)({
35
+ name: 'classify_ticket',
36
+ description: 'Classifies a support ticket as high or normal severity based on urgency keywords.',
37
+ schema: ClassifyTicketSchema,
38
+ resultSchema: exports.ClassificationResultSchema,
39
+ effects: 'none',
40
+ })
41
+ ], ClassifyTicketTool);
42
+ //# sourceMappingURL=classify-ticket.tool.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"classify-ticket.tool.js","sourceRoot":"","sources":["../../src/triage/classify-ticket.tool.ts"],"names":[],"mappings":";;;;;;;;;AAAA,6BAAwB;AACxB,8CAAiE;AAEjE,MAAM,oBAAoB,GAAG,OAAC;KAC3B,MAAM,CAAC;IACN,IAAI,EAAE,OAAC,CAAC,MAAM,EAAE;CACjB,CAAC;KACD,MAAM,EAAE,CAAC;AASC,QAAA,0BAA0B,GAAG,OAAC,CAAC,YAAY,CAAC;IACvD,QAAQ,EAAE,OAAC,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;IACpC,MAAM,EAAE,OAAC,CAAC,MAAM,EAAE;CACnB,CAAC,CAAC;AAEH,MAAM,gBAAgB,GAAG,CAAC,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,UAAU,CAAC,CAAC;AAS3D,IAAM,kBAAkB,GAAxB,MAAM,kBAAmB,SAAQ,iBAA0D;IACtF,KAAK,CAAC,MAAM,CAAC,IAAwB;QAC7C,MAAM,OAAO,GAAG,gBAAgB,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC;QACxF,OAAO,OAAO,CAAC,OAAO,CAAC;YACrB,IAAI,EAAE,OAAO;gBACX,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,4BAA4B,OAAO,GAAG,EAAE;gBACtE,CAAC,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,EAAE,qBAAqB,EAAE;SAC1D,CAAC,CAAC;IACL,CAAC;CACF,CAAA;AATY,gDAAkB;6BAAlB,kBAAkB;IAP9B,IAAA,aAAI,EAAC;QACJ,IAAI,EAAE,iBAAiB;QACvB,WAAW,EAAE,mFAAmF;QAChG,MAAM,EAAE,oBAAoB;QAC5B,YAAY,EAAE,kCAA0B;QACxC,OAAO,EAAE,MAAM;KAChB,CAAC;GACW,kBAAkB,CAS9B"}
@@ -0,0 +1,4 @@
1
+ export * from './approval-prompt.document';
2
+ export * from './classify-ticket.tool';
3
+ export * from './triage-ticket.workflow';
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/triage/index.ts"],"names":[],"mappings":"AAAA,cAAc,4BAA4B,CAAC;AAC3C,cAAc,wBAAwB,CAAC;AACvC,cAAc,0BAA0B,CAAC"}
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./approval-prompt.document"), exports);
18
+ __exportStar(require("./classify-ticket.tool"), exports);
19
+ __exportStar(require("./triage-ticket.workflow"), exports);
20
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/triage/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,6DAA2C;AAC3C,yDAAuC;AACvC,2DAAyC"}
@@ -0,0 +1,23 @@
1
+ import { z } from 'zod';
2
+ import { BaseWorkflow } from '@loopstack/common';
3
+ import type { RunContext, TransitionInput } from '@loopstack/common';
4
+ import { ClassifyTicketTool } from './classify-ticket.tool';
5
+ declare const TriageTicketSchema: z.ZodObject<{
6
+ text: z.ZodString;
7
+ }, z.core.$strip>;
8
+ type TriageTicketArgs = z.infer<typeof TriageTicketSchema>;
9
+ interface TriageTicketState {
10
+ severity?: 'high' | 'normal';
11
+ reason?: string;
12
+ }
13
+ export declare class TriageTicketWorkflow extends BaseWorkflow<TriageTicketArgs> {
14
+ private readonly classifyTicket;
15
+ constructor(classifyTicket: ClassifyTicketTool);
16
+ classify(state: TriageTicketState, ctx: RunContext<TriageTicketArgs>): Promise<void>;
17
+ report(state: TriageTicketState): Promise<void>;
18
+ approve(state: TriageTicketState, input: TransitionInput<{
19
+ answer: 'yes' | 'no';
20
+ }>): void;
21
+ }
22
+ export {};
23
+ //# sourceMappingURL=triage-ticket.workflow.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"triage-ticket.workflow.d.ts","sourceRoot":"","sources":["../../src/triage/triage-ticket.workflow.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,YAAY,EAAwB,MAAM,mBAAmB,CAAC;AACvE,OAAO,KAAK,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAErE,OAAO,EAAwB,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAElF,QAAA,MAAM,kBAAkB;;iBAEtB,CAAC;AAEH,KAAK,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAE3D,UAAU,iBAAiB;IACzB,QAAQ,CAAC,EAAE,MAAM,GAAG,QAAQ,CAAC;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAID,qBAOa,oBAAqB,SAAQ,YAAY,CAAC,gBAAgB,CAAC;IAC1D,OAAO,CAAC,QAAQ,CAAC,cAAc;gBAAd,cAAc,EAAE,kBAAkB;IAKzD,QAAQ,CAAC,KAAK,EAAE,iBAAiB,EAAE,GAAG,EAAE,UAAU,CAAC,gBAAgB,CAAC;IAOpE,MAAM,CAAC,KAAK,EAAE,iBAAiB;IAYrC,OAAO,CAAC,KAAK,EAAE,iBAAiB,EAAE,KAAK,EAAE,eAAe,CAAC;QAAE,MAAM,EAAE,KAAK,GAAG,IAAI,CAAA;KAAE,CAAC;CAMnF"}
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __metadata = (this && this.__metadata) || function (k, v) {
9
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.TriageTicketWorkflow = void 0;
13
+ const zod_1 = require("zod");
14
+ const common_1 = require("@loopstack/common");
15
+ const approval_prompt_document_1 = require("./approval-prompt.document");
16
+ const classify_ticket_tool_1 = require("./classify-ticket.tool");
17
+ const TriageTicketSchema = zod_1.z.object({
18
+ text: zod_1.z.string(),
19
+ });
20
+ const ApprovalSchema = zod_1.z.object({ answer: zod_1.z.enum(['yes', 'no']) });
21
+ let TriageTicketWorkflow = class TriageTicketWorkflow extends common_1.BaseWorkflow {
22
+ classifyTicket;
23
+ constructor(classifyTicket) {
24
+ super();
25
+ this.classifyTicket = classifyTicket;
26
+ }
27
+ async classify(state, ctx) {
28
+ const result = await this.classifyTicket.call({ text: ctx.args.text });
29
+ const { severity, reason } = result.data;
30
+ this.assignState({ severity, reason });
31
+ }
32
+ async report(state) {
33
+ await this.documentStore.save(approval_prompt_document_1.ApprovalPromptDocument, {
34
+ severity: state.severity,
35
+ question: `Ticket severity: ${state.severity} (${state.reason}). Approve this classification?`,
36
+ }, { key: 'approval_prompt' });
37
+ }
38
+ approve(state, input) {
39
+ this.setResult({ severity: state.severity, approved: input.data.answer === 'yes' });
40
+ }
41
+ };
42
+ exports.TriageTicketWorkflow = TriageTicketWorkflow;
43
+ __decorate([
44
+ (0, common_1.Transition)({ to: 'classified' }),
45
+ __metadata("design:type", Function),
46
+ __metadata("design:paramtypes", [Object, Object]),
47
+ __metadata("design:returntype", Promise)
48
+ ], TriageTicketWorkflow.prototype, "classify", null);
49
+ __decorate([
50
+ (0, common_1.Transition)({ from: 'classified', to: 'awaiting_approval' }),
51
+ __metadata("design:type", Function),
52
+ __metadata("design:paramtypes", [Object]),
53
+ __metadata("design:returntype", Promise)
54
+ ], TriageTicketWorkflow.prototype, "report", null);
55
+ __decorate([
56
+ (0, common_1.Transition)({ from: 'awaiting_approval', to: 'end', wait: true, schema: ApprovalSchema }),
57
+ __metadata("design:type", Function),
58
+ __metadata("design:paramtypes", [Object, Object]),
59
+ __metadata("design:returntype", void 0)
60
+ ], TriageTicketWorkflow.prototype, "approve", null);
61
+ exports.TriageTicketWorkflow = TriageTicketWorkflow = __decorate([
62
+ (0, common_1.Workflow)({
63
+ name: 'triage_ticket',
64
+ title: 'Testing - Ticket Triage Example',
65
+ description: 'Classifies a support ticket with a tool, presents the severity as a yes/no approval prompt, and waits for the human decision — the example workflow behind the testing guide.',
66
+ schema: TriageTicketSchema,
67
+ }),
68
+ __metadata("design:paramtypes", [classify_ticket_tool_1.ClassifyTicketTool])
69
+ ], TriageTicketWorkflow);
70
+ //# sourceMappingURL=triage-ticket.workflow.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"triage-ticket.workflow.js","sourceRoot":"","sources":["../../src/triage/triage-ticket.workflow.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,6BAAwB;AACxB,8CAAuE;AAEvE,yEAAoE;AACpE,iEAAkF;AAElF,MAAM,kBAAkB,GAAG,OAAC,CAAC,MAAM,CAAC;IAClC,IAAI,EAAE,OAAC,CAAC,MAAM,EAAE;CACjB,CAAC,CAAC;AASH,MAAM,cAAc,GAAG,OAAC,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,OAAC,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC;AAS5D,IAAM,oBAAoB,GAA1B,MAAM,oBAAqB,SAAQ,qBAA8B;IACzC;IAA7B,YAA6B,cAAkC;QAC7D,KAAK,EAAE,CAAC;QADmB,mBAAc,GAAd,cAAc,CAAoB;IAE/D,CAAC;IAGK,AAAN,KAAK,CAAC,QAAQ,CAAC,KAAwB,EAAE,GAAiC;QACxE,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,cAAc,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC;QACvE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,MAAM,CAAC,IAA4B,CAAC;QACjE,IAAI,CAAC,WAAW,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC;IACzC,CAAC;IAGK,AAAN,KAAK,CAAC,MAAM,CAAC,KAAwB;QACnC,MAAM,IAAI,CAAC,aAAa,CAAC,IAAI,CAC3B,iDAAsB,EACtB;YACE,QAAQ,EAAE,KAAK,CAAC,QAAS;YACzB,QAAQ,EAAE,oBAAoB,KAAK,CAAC,QAAQ,KAAK,KAAK,CAAC,MAAM,iCAAiC;SAC/F,EACD,EAAE,GAAG,EAAE,iBAAiB,EAAE,CAC3B,CAAC;IACJ,CAAC;IAGD,OAAO,CAAC,KAAwB,EAAE,KAAgD;QAChF,IAAI,CAAC,SAAS,CAAC,EAAE,QAAQ,EAAE,KAAK,CAAC,QAAQ,EAAE,QAAQ,EAAE,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,KAAK,EAG/E,CAAC,CAAC;IACL,CAAC;CACF,CAAA;AA/BY,oDAAoB;AAMzB;IADL,IAAA,mBAAU,EAAC,EAAE,EAAE,EAAE,YAAY,EAAE,CAAC;;;;oDAKhC;AAGK;IADL,IAAA,mBAAU,EAAC,EAAE,IAAI,EAAE,YAAY,EAAE,EAAE,EAAE,mBAAmB,EAAE,CAAC;;;;kDAU3D;AAGD;IADC,IAAA,mBAAU,EAAC,EAAE,IAAI,EAAE,mBAAmB,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,cAAc,EAAE,CAAC;;;;mDAMxF;+BA9BU,oBAAoB;IAPhC,IAAA,iBAAQ,EAAC;QACR,IAAI,EAAE,eAAe;QACrB,KAAK,EAAE,iCAAiC;QACxC,WAAW,EACT,+KAA+K;QACjL,MAAM,EAAE,kBAAkB;KAC3B,CAAC;qCAE6C,yCAAkB;GADpD,oBAAoB,CA+BhC"}
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@loopstack/testing-examples",
3
3
  "displayName": "Loopstack Testing Examples",
4
- "description": "Placeholder for workflow testing examples — fixtures, assertions, mocked LLM responses (coming soon).",
4
+ "description": "Workflow testing examples — runWorkflow in-process tests, scripted HITL answers, tool unit tests with testTool, and record/replay fixtures.",
5
5
  "keywords": [
6
6
  "loopstack",
7
7
  "example",
8
8
  "testing",
9
9
  "workflow"
10
10
  ],
11
- "version": "0.1.0",
11
+ "version": "0.2.0",
12
12
  "license": "MIT",
13
13
  "author": {
14
14
  "name": "Jakob Klippel",
@@ -30,13 +30,15 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@loopstack/common": "*",
33
- "@nestjs/common": "^11.1.19"
33
+ "@nestjs/common": "^11.1.19",
34
+ "zod": "^4.3.6"
34
35
  },
35
36
  "files": [
36
37
  "dist",
37
38
  "src"
38
39
  ],
39
40
  "devDependencies": {
41
+ "@loopstack/testing": "*",
40
42
  "vitest": "^4.1.6",
41
43
  "@swc/core": "^1.15.33",
42
44
  "unplugin-swc": "^1.5.9"
package/src/index.ts CHANGED
@@ -1 +1,2 @@
1
1
  export * from './testing-examples.module';
2
+ export * from './triage';
@@ -1,9 +1,13 @@
1
1
  import { Module } from '@nestjs/common';
2
2
  import { StudioApp } from '@loopstack/common';
3
+ import { ClassifyTicketTool } from './triage/classify-ticket.tool';
4
+ import { TriageTicketWorkflow } from './triage/triage-ticket.workflow';
3
5
 
4
6
  @StudioApp({
5
7
  title: 'Testing Examples',
6
- workflows: [],
8
+ workflows: [TriageTicketWorkflow],
9
+ })
10
+ @Module({
11
+ providers: [TriageTicketWorkflow, ClassifyTicketTool],
7
12
  })
8
- @Module({})
9
13
  export class TestingExamplesModule {}
@@ -0,0 +1,17 @@
1
+ {
2
+ "version": 3,
3
+ "recordings": [
4
+ {
5
+ "tool": "classify_ticket",
6
+ "workflow": "triage_ticket",
7
+ "transition": "classify",
8
+ "args": { "text": "Production is down!" },
9
+ "envelope": {
10
+ "data": {
11
+ "severity": "high",
12
+ "reason": "replayed from recording"
13
+ }
14
+ }
15
+ }
16
+ ]
17
+ }
@@ -0,0 +1,36 @@
1
+ import type { TestingModule } from '@nestjs/testing';
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { testTool } from '@loopstack/testing';
4
+ import { ClassifyTicketTool } from '../classify-ticket.tool';
5
+
6
+ /**
7
+ * Tool unit test: the smallest testable unit — given args, assert on the returned envelope
8
+ * data. `call()` runs the tool through the real pipeline, so the declared `resultSchema` is
9
+ * validated on the way out.
10
+ *
11
+ * Acceptance criteria:
12
+ * C1 — urgency keywords grade a ticket as high severity, naming the matched keyword.
13
+ * C2 — a routine ticket grades as normal severity.
14
+ */
15
+ describe('ClassifyTicketTool', () => {
16
+ let module: TestingModule;
17
+ afterEach(() => module?.close());
18
+
19
+ it('C1: grades an outage as high severity', async () => {
20
+ module = await testTool().forTool(ClassifyTicketTool).compile();
21
+ const tool = module.get(ClassifyTicketTool);
22
+
23
+ const result = await tool.call({ text: 'Production is down!' });
24
+
25
+ expect(result.data).toMatchObject({ severity: 'high', reason: 'matched urgency keyword "down"' });
26
+ });
27
+
28
+ it('C2: grades a routine question as normal severity', async () => {
29
+ module = await testTool().forTool(ClassifyTicketTool).compile();
30
+ const tool = module.get(ClassifyTicketTool);
31
+
32
+ const result = await tool.call({ text: 'How do I change my invoice address?' });
33
+
34
+ expect(result.data).toMatchObject({ severity: 'normal', reason: 'no urgency keywords' });
35
+ });
36
+ });
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createContractFake, runWorkflow } from '@loopstack/testing';
3
+ import { ClassifyTicketTool } from '../classify-ticket.tool';
4
+ import { TriageTicketWorkflow } from '../triage-ticket.workflow';
5
+
6
+ /**
7
+ * Contract fake: a DI-level tool replacement whose scripted response is validated against
8
+ * the tool's declared `resultSchema` at scripting time. Use it when you want to control a
9
+ * tool's output *and* assert how the workflow called it (`fake.call` is a vitest mock) —
10
+ * the contract-honest alternative to a free-form mock and the lighter-weight cousin of a
11
+ * recorded fixture. A scripted envelope that drifts from the tool's real result shape fails
12
+ * the test instead of passing against a stale mock.
13
+ */
14
+ describe('TriageTicketWorkflow — contract fake', () => {
15
+ it('drives the workflow with a scripted classification and asserts the call', async () => {
16
+ const classify = createContractFake(ClassifyTicketTool);
17
+ classify.returns({ data: { severity: 'high', reason: 'scripted by the contract fake' } });
18
+
19
+ const run = await runWorkflow(
20
+ TriageTicketWorkflow,
21
+ { text: 'Production is down!' },
22
+ {
23
+ // Substitute the tool with the fake. (For a tool that comes from an imported module,
24
+ // reach it with `overrides: [[Tool, fake]]` instead.)
25
+ providers: [{ provide: ClassifyTicketTool, useValue: classify }],
26
+ answers: { approve: { answer: 'yes' } },
27
+ },
28
+ );
29
+
30
+ expect(run.status).toBe('completed');
31
+ expect(run.result).toMatchObject({ severity: 'high', approved: true });
32
+ // The workflow passed the ticket text straight through to the tool.
33
+ expect(classify.call).toHaveBeenCalledWith({ text: 'Production is down!' });
34
+ expect(run.document('approval_prompt')).toMatchObject({
35
+ question: expect.stringContaining('scripted by the contract fake'),
36
+ });
37
+ });
38
+ });
@@ -0,0 +1,62 @@
1
+ import { join } from 'node:path';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { replay, runWorkflow } from '@loopstack/testing';
4
+ import { ClassifyTicketTool } from '../classify-ticket.tool';
5
+ import { TriageTicketWorkflow } from '../triage-ticket.workflow';
6
+
7
+ /**
8
+ * Record/replay: recorded tool responses are returned instead of executing the tool — the
9
+ * workflow's own code still runs for real. Replaying makes non-deterministic tools (above
10
+ * all LLM calls) deterministic; here the classifier is deterministic already, so the point
11
+ * is the mechanism itself.
12
+ *
13
+ * The `fixture:` option is the default path — it replays when the file exists and records a
14
+ * fresh fixture when it is missing (in CI a missing fixture is an error, so a forgotten
15
+ * `git add` can never silently downgrade the test into a live run). Record a fixture from a
16
+ * real run:
17
+ *
18
+ * loopstack run triage_ticket --arg text="Production is down!" --trace
19
+ * loopstack runs <run-id> --record src/triage/__tests__/__recordings__/triage.json
20
+ */
21
+ describe('TriageTicketWorkflow — record/replay', () => {
22
+ it('replays the committed fixture instead of executing the tool', async () => {
23
+ const run = await runWorkflow(
24
+ TriageTicketWorkflow,
25
+ { text: 'Production is down!' },
26
+ {
27
+ providers: [ClassifyTicketTool],
28
+ answers: { approve: { answer: 'yes' } },
29
+ fixture: join(__dirname, '__recordings__/triage.json'),
30
+ },
31
+ );
32
+
33
+ expect(run.status).toBe('completed');
34
+ // The reason text can only come from the fixture — proof the live tool never ran.
35
+ expect(run.result).toMatchObject({ severity: 'high', approved: true });
36
+ expect(run.document('approval_prompt')).toMatchObject({
37
+ question: expect.stringContaining('replayed from recording'),
38
+ });
39
+ });
40
+
41
+ it('accepts an inline fixture for fully-scripted determinism', async () => {
42
+ const run = await runWorkflow(
43
+ TriageTicketWorkflow,
44
+ { text: 'anything at all' },
45
+ {
46
+ providers: [ClassifyTicketTool],
47
+ answers: { approve: { answer: 'yes' } },
48
+ replay: replay({
49
+ version: 3,
50
+ recordings: [
51
+ {
52
+ tool: 'classify_ticket',
53
+ envelope: { data: { severity: 'normal', reason: 'inline fixture' } },
54
+ },
55
+ ],
56
+ }),
57
+ },
58
+ );
59
+
60
+ expect(run.result).toMatchObject({ severity: 'normal', approved: true });
61
+ });
62
+ });
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { type TestRun, coverage, runWorkflow } from '@loopstack/testing';
3
+ import { TestingExamplesModule } from '../../testing-examples.module';
4
+ import { ClassifyTicketTool } from '../classify-ticket.tool';
5
+ import { TriageTicketWorkflow } from '../triage-ticket.workflow';
6
+
7
+ /**
8
+ * Workflow tests: the real state machine runs in-process — no backend, no database.
9
+ * Each scenario is named for the acceptance criterion it checks (`C1:`…), so the test
10
+ * report reads as a criteria matrix (see the Testing Methodology guide). Assert on
11
+ * outcomes — status, path, result, documents, and what the user sees at a park — never
12
+ * on internal mechanics.
13
+ *
14
+ * Acceptance criteria:
15
+ * C1 — a high-severity ticket is classified, reported, and (on approval) completes as approved.
16
+ * C2 — while awaiting approval the run parks showing a yes/no prompt with the severity.
17
+ * C3 — a rejected classification completes as not approved.
18
+ */
19
+ describe('TriageTicketWorkflow', () => {
20
+ const runs: TestRun[] = [];
21
+ const run = async (text: string, answer?: 'yes' | 'no') => {
22
+ const result = await runWorkflow(
23
+ TriageTicketWorkflow,
24
+ { text },
25
+ {
26
+ // Import the app module so the document's widget config is discoverable —
27
+ // parkView() resolves the same widget the CLI and Studio render.
28
+ imports: [TestingExamplesModule],
29
+ providers: [ClassifyTicketTool],
30
+ ...(answer ? { answers: { approve: { answer } } } : {}),
31
+ },
32
+ );
33
+ runs.push(result);
34
+ return result;
35
+ };
36
+
37
+ it('C1: classifies an outage as high severity and completes when approved', async () => {
38
+ const result = await run('Production is down!', 'yes');
39
+
40
+ expect(result.status).toBe('completed');
41
+ expect(result.path).toEqual(['classify', 'report', 'approve']);
42
+ expect(result.result).toMatchObject({ severity: 'high', approved: true });
43
+ expect(result.document('approval_prompt')).toMatchObject({
44
+ severity: 'high',
45
+ question: 'Ticket severity: high (matched urgency keyword "down"). Approve this classification?',
46
+ });
47
+ });
48
+
49
+ it('C2: parks for approval showing the severity prompt', async () => {
50
+ const result = await run('Where is my invoice?');
51
+
52
+ expect(result.status).toBe('waiting');
53
+ expect(result.place).toBe('awaiting_approval');
54
+ expect(result.path).toEqual(['classify', 'report']);
55
+
56
+ // What the human would see at this park — resolved by the same rules the CLI and Studio use.
57
+ const view = result.parkView();
58
+ expect(view).toMatchObject({
59
+ widget: 'confirm-prompt',
60
+ documentName: 'approval_prompt',
61
+ content: { severity: 'normal', question: expect.stringContaining('normal') },
62
+ transitions: ['approve'],
63
+ defaultTransition: 'approve',
64
+ });
65
+ });
66
+
67
+ it('C3: records a rejected classification', async () => {
68
+ const result = await run('Urgent: cannot log in', 'no');
69
+
70
+ expect(result.status).toBe('completed');
71
+ expect(result.result).toMatchObject({ severity: 'high', approved: false });
72
+ });
73
+
74
+ it('covers every declared transition and park (coverage gate)', () => {
75
+ const cov = coverage(runs, TriageTicketWorkflow);
76
+ expect(cov.missingTransitions).toEqual([]);
77
+ expect(cov.missingParks).toEqual([]);
78
+ expect(cov.complete).toBe(true);
79
+ });
80
+ });
@@ -0,0 +1,28 @@
1
+ import { z } from 'zod';
2
+ import { Document } from '@loopstack/common';
3
+
4
+ export const ApprovalPromptSchema = z
5
+ .object({
6
+ question: z.string(),
7
+ severity: z.enum(['high', 'normal']),
8
+ answer: z.string().optional(),
9
+ })
10
+ .strict();
11
+
12
+ export type ApprovalPromptType = z.infer<typeof ApprovalPromptSchema>;
13
+
14
+ /**
15
+ * The human touchpoint of the triage flow: a yes/no approval prompt shown while the
16
+ * workflow waits. The inline `confirm-prompt` widget binds the "Yes"/"No" answer to the
17
+ * `approve` wait transition — the same widget the CLI and Studio render, so a test can
18
+ * assert `parkView()` (widget, question, default transition) exactly as a user would see it.
19
+ */
20
+ @Document({
21
+ schema: ApprovalPromptSchema,
22
+ widget: { widget: 'confirm-prompt', options: { transition: 'approve' } },
23
+ })
24
+ export class ApprovalPromptDocument {
25
+ question: string;
26
+ severity: 'high' | 'normal';
27
+ answer?: string;
28
+ }
@@ -0,0 +1,40 @@
1
+ import { z } from 'zod';
2
+ import { BaseTool, Tool, ToolEnvelope } from '@loopstack/common';
3
+
4
+ const ClassifyTicketSchema = z
5
+ .object({
6
+ text: z.string(),
7
+ })
8
+ .strict();
9
+
10
+ type ClassifyTicketArgs = z.infer<typeof ClassifyTicketSchema>;
11
+
12
+ export interface ClassificationResult {
13
+ severity: 'high' | 'normal';
14
+ reason: string;
15
+ }
16
+
17
+ export const ClassificationResultSchema = z.strictObject({
18
+ severity: z.enum(['high', 'normal']),
19
+ reason: z.string(),
20
+ });
21
+
22
+ const URGENCY_KEYWORDS = ['down', 'outage', 'urgent', 'critical'];
23
+
24
+ @Tool({
25
+ name: 'classify_ticket',
26
+ description: 'Classifies a support ticket as high or normal severity based on urgency keywords.',
27
+ schema: ClassifyTicketSchema,
28
+ resultSchema: ClassificationResultSchema,
29
+ effects: 'none',
30
+ })
31
+ export class ClassifyTicketTool extends BaseTool<ClassifyTicketArgs, object, ClassificationResult> {
32
+ protected async handle(args: ClassifyTicketArgs): Promise<ToolEnvelope<ClassificationResult>> {
33
+ const matched = URGENCY_KEYWORDS.find((word) => args.text.toLowerCase().includes(word));
34
+ return Promise.resolve({
35
+ data: matched
36
+ ? { severity: 'high', reason: `matched urgency keyword "${matched}"` }
37
+ : { severity: 'normal', reason: 'no urgency keywords' },
38
+ });
39
+ }
40
+ }
@@ -0,0 +1,3 @@
1
+ export * from './approval-prompt.document';
2
+ export * from './classify-ticket.tool';
3
+ export * from './triage-ticket.workflow';
@@ -0,0 +1,58 @@
1
+ import { z } from 'zod';
2
+ import { BaseWorkflow, Transition, Workflow } from '@loopstack/common';
3
+ import type { RunContext, TransitionInput } from '@loopstack/common';
4
+ import { ApprovalPromptDocument } from './approval-prompt.document';
5
+ import { ClassificationResult, ClassifyTicketTool } from './classify-ticket.tool';
6
+
7
+ const TriageTicketSchema = z.object({
8
+ text: z.string(),
9
+ });
10
+
11
+ type TriageTicketArgs = z.infer<typeof TriageTicketSchema>;
12
+
13
+ interface TriageTicketState {
14
+ severity?: 'high' | 'normal';
15
+ reason?: string;
16
+ }
17
+
18
+ const ApprovalSchema = z.object({ answer: z.enum(['yes', 'no']) });
19
+
20
+ @Workflow({
21
+ name: 'triage_ticket',
22
+ title: 'Testing - Ticket Triage Example',
23
+ description:
24
+ 'Classifies a support ticket with a tool, presents the severity as a yes/no approval prompt, and waits for the human decision — the example workflow behind the testing guide.',
25
+ schema: TriageTicketSchema,
26
+ })
27
+ export class TriageTicketWorkflow extends BaseWorkflow<TriageTicketArgs> {
28
+ constructor(private readonly classifyTicket: ClassifyTicketTool) {
29
+ super();
30
+ }
31
+
32
+ @Transition({ to: 'classified' })
33
+ async classify(state: TriageTicketState, ctx: RunContext<TriageTicketArgs>) {
34
+ const result = await this.classifyTicket.call({ text: ctx.args.text });
35
+ const { severity, reason } = result.data as ClassificationResult;
36
+ this.assignState({ severity, reason });
37
+ }
38
+
39
+ @Transition({ from: 'classified', to: 'awaiting_approval' })
40
+ async report(state: TriageTicketState) {
41
+ await this.documentStore.save(
42
+ ApprovalPromptDocument,
43
+ {
44
+ severity: state.severity!,
45
+ question: `Ticket severity: ${state.severity} (${state.reason}). Approve this classification?`,
46
+ },
47
+ { key: 'approval_prompt' },
48
+ );
49
+ }
50
+
51
+ @Transition({ from: 'awaiting_approval', to: 'end', wait: true, schema: ApprovalSchema })
52
+ approve(state: TriageTicketState, input: TransitionInput<{ answer: 'yes' | 'no' }>) {
53
+ this.setResult({ severity: state.severity, approved: input.data.answer === 'yes' } as unknown as Record<
54
+ string,
55
+ unknown
56
+ >);
57
+ }
58
+ }