@specific.dev/spectest 0.105.0 → 0.106.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.
- package/README.md +21 -0
- package/dist/annotate.d.ts +21 -0
- package/dist/annotate.js +22 -1
- package/dist/recorder.d.ts +9 -0
- package/package.json +1 -1
- package/src/annotate.test.ts +60 -2
- package/src/annotate.ts +44 -1
- package/src/recorder.ts +11 -1
package/README.md
CHANGED
|
@@ -86,6 +86,27 @@ return annotate(turns, "chat", {
|
|
|
86
86
|
The dashboard draws the chips on the bubble's lower corner, opposite the
|
|
87
87
|
sender, and slides a new one in after the bubbles that arrived with it.
|
|
88
88
|
|
|
89
|
+
## Read receipts and typing (0.106.0)
|
|
90
|
+
|
|
91
|
+
A `chat` message can carry its delivery `status` (`delivered` or `read`)
|
|
92
|
+
and an optional `statusAt` — free text such as `"23:14"` — drawn as the
|
|
93
|
+
caption a messaging app shows under the bubble, on the bubble's own side.
|
|
94
|
+
The annotation itself takes `typing: "self" | "other"`, drawn as the
|
|
95
|
+
three-dot bubble at the end of the thread on that side. Both are the fake's
|
|
96
|
+
to set, like `new`; typing is transient, so a step either shows it or not.
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
return annotate(turns, "chat", {
|
|
100
|
+
typing: state.agentTyping ? "other" : undefined,
|
|
101
|
+
messages: turns.map((t) => ({
|
|
102
|
+
side: t.fromCustomer ? "self" : "other",
|
|
103
|
+
text: t.body,
|
|
104
|
+
status: t.readAt ? "read" : "delivered",
|
|
105
|
+
statusAt: t.readAt,
|
|
106
|
+
})),
|
|
107
|
+
});
|
|
108
|
+
```
|
|
109
|
+
|
|
89
110
|
## HTTP assertions
|
|
90
111
|
|
|
91
112
|
`ctx.fetch()` returns a wrapped response. Status fields, header lookups, and
|
package/dist/annotate.d.ts
CHANGED
|
@@ -95,6 +95,14 @@ export interface ChatAnnotation {
|
|
|
95
95
|
* reads fine without it.
|
|
96
96
|
*/
|
|
97
97
|
title?: string;
|
|
98
|
+
/**
|
|
99
|
+
* Who is writing right now — `self` the end user, `other` whoever they
|
|
100
|
+
* are talking to — drawn as the three-dot bubble at the end of the
|
|
101
|
+
* thread on that side. It is the thread's present tense: a call either
|
|
102
|
+
* shows it or not, so there is no `new` to mark. Any other value is
|
|
103
|
+
* nobody typing.
|
|
104
|
+
*/
|
|
105
|
+
typing?: "self" | "other";
|
|
98
106
|
}
|
|
99
107
|
/** One turn of a {@link ChatAnnotation}. */
|
|
100
108
|
export interface ChatMessageAnnotation {
|
|
@@ -119,6 +127,19 @@ export interface ChatMessageAnnotation {
|
|
|
119
127
|
* corner. A reaction that was removed simply stops being listed.
|
|
120
128
|
*/
|
|
121
129
|
reactions?: readonly ChatReactionAnnotation[];
|
|
130
|
+
/**
|
|
131
|
+
* Delivery status, as the provider reported it: drawn as the small
|
|
132
|
+
* caption under the bubble ("Delivered", "Read"), on the bubble's own
|
|
133
|
+
* side. Any other value is left off — a caption, unlike a turn, can be
|
|
134
|
+
* dropped without the transcript lying.
|
|
135
|
+
*/
|
|
136
|
+
status?: "delivered" | "read";
|
|
137
|
+
/**
|
|
138
|
+
* When, as the app would show it ("23:14"): free text drawn after the
|
|
139
|
+
* status ("Read 23:14"). Rides with `status`; alone it has nothing to
|
|
140
|
+
* qualify and is dropped.
|
|
141
|
+
*/
|
|
142
|
+
statusAt?: string;
|
|
122
143
|
}
|
|
123
144
|
/** One reaction on a {@link ChatMessageAnnotation}. */
|
|
124
145
|
export interface ChatReactionAnnotation {
|
package/dist/annotate.js
CHANGED
|
@@ -138,6 +138,7 @@ function lowerChat(options, value) {
|
|
|
138
138
|
const source = deepUnwrap(options ?? value);
|
|
139
139
|
let list;
|
|
140
140
|
let title;
|
|
141
|
+
let typing;
|
|
141
142
|
if (Array.isArray(source)) {
|
|
142
143
|
list = source;
|
|
143
144
|
}
|
|
@@ -145,6 +146,7 @@ function lowerChat(options, value) {
|
|
|
145
146
|
const bag = fields(source);
|
|
146
147
|
list = bag.messages;
|
|
147
148
|
title = text(bag.title) || undefined;
|
|
149
|
+
typing = typingOf(bag.typing);
|
|
148
150
|
}
|
|
149
151
|
// An empty conversation is a real state (nothing said yet) and renders as
|
|
150
152
|
// one; a value with no `messages` at all is the author's mistake.
|
|
@@ -157,7 +159,9 @@ function lowerChat(options, value) {
|
|
|
157
159
|
return {
|
|
158
160
|
kind: "chat",
|
|
159
161
|
title,
|
|
160
|
-
blocks: [
|
|
162
|
+
blocks: [
|
|
163
|
+
{ type: "chat", messages, ...(title ? { label: title } : {}), ...(typing ? { typing } : {}) },
|
|
164
|
+
],
|
|
161
165
|
};
|
|
162
166
|
}
|
|
163
167
|
function chatMessageOf(src) {
|
|
@@ -176,8 +180,25 @@ function chatMessageOf(src) {
|
|
|
176
180
|
const reactions = reactionsOf(src.reactions);
|
|
177
181
|
if (reactions.length > 0)
|
|
178
182
|
msg.reactions = reactions;
|
|
183
|
+
// A status caption, unlike a turn, can be left off without the
|
|
184
|
+
// transcript lying, so one that isn't `delivered` or `read` is dropped
|
|
185
|
+
// rather than guessed at. Its time rides with it: free text, truncated
|
|
186
|
+
// like a body, and meaningless without a status to qualify.
|
|
187
|
+
const status = src.status;
|
|
188
|
+
if (status === "delivered" || status === "read") {
|
|
189
|
+
msg.status = status;
|
|
190
|
+
if (typeof src.statusAt === "string" && src.statusAt !== "") {
|
|
191
|
+
msg.statusAt = truncateUtf8(src.statusAt).value;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
179
194
|
return msg;
|
|
180
195
|
}
|
|
196
|
+
/** The block's `typing`: `self` or `other`, else nobody. The thread's
|
|
197
|
+
* present tense is a decoration, not a turn, so a value that isn't one of
|
|
198
|
+
* the two draws nothing rather than guessing at who. */
|
|
199
|
+
function typingOf(v) {
|
|
200
|
+
return v === "self" || v === "other" ? v : undefined;
|
|
201
|
+
}
|
|
181
202
|
/** Reactions get the tolerance `side` gets: an entry that isn't quite right
|
|
182
203
|
* is drawn rather than dropped — a `from` that is missing or misspelled is
|
|
183
204
|
* the other party's — and only one with nothing to draw (no string
|
package/dist/recorder.d.ts
CHANGED
|
@@ -631,6 +631,7 @@ export type StepBlock = {
|
|
|
631
631
|
type: "chat";
|
|
632
632
|
messages: ChatBlockMessage[];
|
|
633
633
|
label?: string;
|
|
634
|
+
typing?: "self" | "other";
|
|
634
635
|
} | {
|
|
635
636
|
type: "details";
|
|
636
637
|
summary: string;
|
|
@@ -651,6 +652,14 @@ export interface ChatBlockMessage {
|
|
|
651
652
|
* corner, on the side opposite the sender. Absent and empty read the
|
|
652
653
|
* same: nothing to draw. */
|
|
653
654
|
reactions?: ChatBlockReaction[];
|
|
655
|
+
/** Delivery status — the caption a messaging app shows under a bubble
|
|
656
|
+
* ("Delivered", "Read"), on the bubble's own side. Absent draws
|
|
657
|
+
* nothing. */
|
|
658
|
+
status?: "delivered" | "read";
|
|
659
|
+
/** When the status happened, as the app would show it ("23:14"): free
|
|
660
|
+
* text drawn after the status caption ("Read 23:14"). Rides with
|
|
661
|
+
* `status`; alone it has nothing to qualify. */
|
|
662
|
+
statusAt?: string;
|
|
654
663
|
}
|
|
655
664
|
/** One reaction on a {@link ChatBlockMessage}. */
|
|
656
665
|
export interface ChatBlockReaction {
|
package/package.json
CHANGED
package/src/annotate.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
-
import { annotate, readAnnotation } from "./annotate.js";
|
|
3
|
+
import { annotate, readAnnotation, type ChatMessageAnnotation } from "./annotate.js";
|
|
4
4
|
|
|
5
5
|
/** Open a chat annotation: the messages its one block lowered to, and the
|
|
6
6
|
* value the test would receive. */
|
|
@@ -9,7 +9,7 @@ function chat(boxed: unknown) {
|
|
|
9
9
|
if (!opened || opened.annotation.kind !== "chat") throw new Error("not a chat annotation");
|
|
10
10
|
const block = opened.annotation.blocks[0];
|
|
11
11
|
if (!block || block.type !== "chat") throw new Error("not a chat block");
|
|
12
|
-
return { messages: block.messages, value: opened.value };
|
|
12
|
+
return { messages: block.messages, typing: block.typing, block, value: opened.value };
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
describe("annotate chat", () => {
|
|
@@ -77,3 +77,61 @@ describe("annotate chat", () => {
|
|
|
77
77
|
expect(messages[0]!.reactions).toEqual([{ emoji: "✅", from: "self", new: true }]);
|
|
78
78
|
});
|
|
79
79
|
});
|
|
80
|
+
|
|
81
|
+
describe("annotate chat status and typing", () => {
|
|
82
|
+
test("captions a message with its delivery status and leaves the rest alone", () => {
|
|
83
|
+
const { messages } = chat(
|
|
84
|
+
annotate(
|
|
85
|
+
[
|
|
86
|
+
{ side: "self", text: "where is my order?", status: "read", statusAt: "23:14" },
|
|
87
|
+
{ side: "self", text: "hello?", status: "delivered" },
|
|
88
|
+
{ side: "other", text: "It ships today.", new: true },
|
|
89
|
+
],
|
|
90
|
+
"chat",
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
expect(messages[0]).toEqual({ side: "self", text: "where is my order?", status: "read", statusAt: "23:14" });
|
|
94
|
+
// A status without a time carries no `statusAt`; a message without a
|
|
95
|
+
// status carries neither key.
|
|
96
|
+
expect(messages[1]).toEqual({ side: "self", text: "hello?", status: "delivered" });
|
|
97
|
+
expect(messages[2]).toEqual({ side: "other", text: "It ships today.", new: true });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// A caption, unlike a turn, can be left off without the transcript
|
|
101
|
+
// lying: a status that isn't one of the two is dropped, and so is a time
|
|
102
|
+
// that isn't text, is empty, or has no status to qualify.
|
|
103
|
+
test("drops a status it does not know and a time it cannot draw", () => {
|
|
104
|
+
const { messages } = chat(
|
|
105
|
+
annotate(
|
|
106
|
+
[
|
|
107
|
+
{ side: "self", text: "a", status: "seen", statusAt: "23:14" },
|
|
108
|
+
{ side: "self", text: "b", status: "read", statusAt: 2314 },
|
|
109
|
+
{ side: "self", text: "c", statusAt: "23:14" },
|
|
110
|
+
{ side: "self", text: "d", status: "read", statusAt: "" },
|
|
111
|
+
],
|
|
112
|
+
"chat",
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
expect(messages[0]).toEqual({ side: "self", text: "a" });
|
|
116
|
+
expect(messages[1]).toEqual({ side: "self", text: "b", status: "read" });
|
|
117
|
+
expect(messages[2]).toEqual({ side: "self", text: "c" });
|
|
118
|
+
expect(messages[3]).toEqual({ side: "self", text: "d", status: "read" });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("a typing indicator rides on the block, and only for a side it knows", () => {
|
|
122
|
+
const messages: ChatMessageAnnotation[] = [{ side: "other", text: "Let me check." }];
|
|
123
|
+
expect(chat(annotate({ id: 7 }, "chat", { messages, typing: "other" })).typing).toBe("other");
|
|
124
|
+
expect(chat(annotate({ id: 7 }, "chat", { messages, typing: "self" })).typing).toBe("self");
|
|
125
|
+
// Nobody typing is no key at all: absent, misspelled, or a bare flag.
|
|
126
|
+
// (Read off the value, which skips the options' typing.)
|
|
127
|
+
expect(chat(annotate({ id: 7 }, "chat", { messages })).block).toEqual({
|
|
128
|
+
type: "chat",
|
|
129
|
+
messages: [{ side: "other", text: "Let me check." }],
|
|
130
|
+
});
|
|
131
|
+
for (const typing of ["agent", true, null, ""]) {
|
|
132
|
+
expect(chat(annotate({ messages, typing }, "chat")).typing).toBeUndefined();
|
|
133
|
+
}
|
|
134
|
+
// The messages-alone form has no block options to carry it.
|
|
135
|
+
expect(chat(annotate(messages, "chat")).typing).toBeUndefined();
|
|
136
|
+
});
|
|
137
|
+
});
|
package/src/annotate.ts
CHANGED
|
@@ -153,6 +153,14 @@ export interface ChatAnnotation {
|
|
|
153
153
|
* reads fine without it.
|
|
154
154
|
*/
|
|
155
155
|
title?: string;
|
|
156
|
+
/**
|
|
157
|
+
* Who is writing right now — `self` the end user, `other` whoever they
|
|
158
|
+
* are talking to — drawn as the three-dot bubble at the end of the
|
|
159
|
+
* thread on that side. It is the thread's present tense: a call either
|
|
160
|
+
* shows it or not, so there is no `new` to mark. Any other value is
|
|
161
|
+
* nobody typing.
|
|
162
|
+
*/
|
|
163
|
+
typing?: "self" | "other";
|
|
156
164
|
}
|
|
157
165
|
|
|
158
166
|
/** One turn of a {@link ChatAnnotation}. */
|
|
@@ -178,6 +186,19 @@ export interface ChatMessageAnnotation {
|
|
|
178
186
|
* corner. A reaction that was removed simply stops being listed.
|
|
179
187
|
*/
|
|
180
188
|
reactions?: readonly ChatReactionAnnotation[];
|
|
189
|
+
/**
|
|
190
|
+
* Delivery status, as the provider reported it: drawn as the small
|
|
191
|
+
* caption under the bubble ("Delivered", "Read"), on the bubble's own
|
|
192
|
+
* side. Any other value is left off — a caption, unlike a turn, can be
|
|
193
|
+
* dropped without the transcript lying.
|
|
194
|
+
*/
|
|
195
|
+
status?: "delivered" | "read";
|
|
196
|
+
/**
|
|
197
|
+
* When, as the app would show it ("23:14"): free text drawn after the
|
|
198
|
+
* status ("Read 23:14"). Rides with `status`; alone it has nothing to
|
|
199
|
+
* qualify and is dropped.
|
|
200
|
+
*/
|
|
201
|
+
statusAt?: string;
|
|
181
202
|
}
|
|
182
203
|
|
|
183
204
|
/** One reaction on a {@link ChatMessageAnnotation}. */
|
|
@@ -326,12 +347,14 @@ function lowerChat(
|
|
|
326
347
|
const source: unknown = deepUnwrap(options ?? value);
|
|
327
348
|
let list: unknown;
|
|
328
349
|
let title: string | undefined;
|
|
350
|
+
let typing: "self" | "other" | undefined;
|
|
329
351
|
if (Array.isArray(source)) {
|
|
330
352
|
list = source;
|
|
331
353
|
} else {
|
|
332
354
|
const bag = fields(source);
|
|
333
355
|
list = bag.messages;
|
|
334
356
|
title = text(bag.title) || undefined;
|
|
357
|
+
typing = typingOf(bag.typing);
|
|
335
358
|
}
|
|
336
359
|
// An empty conversation is a real state (nothing said yet) and renders as
|
|
337
360
|
// one; a value with no `messages` at all is the author's mistake.
|
|
@@ -343,7 +366,9 @@ function lowerChat(
|
|
|
343
366
|
return {
|
|
344
367
|
kind: "chat",
|
|
345
368
|
title,
|
|
346
|
-
blocks: [
|
|
369
|
+
blocks: [
|
|
370
|
+
{ type: "chat", messages, ...(title ? { label: title } : {}), ...(typing ? { typing } : {}) },
|
|
371
|
+
],
|
|
347
372
|
};
|
|
348
373
|
}
|
|
349
374
|
|
|
@@ -358,9 +383,27 @@ function chatMessageOf(src: Record<string, unknown>): ChatBlockMessage {
|
|
|
358
383
|
if (src.new === true) msg.new = true;
|
|
359
384
|
const reactions = reactionsOf(src.reactions);
|
|
360
385
|
if (reactions.length > 0) msg.reactions = reactions;
|
|
386
|
+
// A status caption, unlike a turn, can be left off without the
|
|
387
|
+
// transcript lying, so one that isn't `delivered` or `read` is dropped
|
|
388
|
+
// rather than guessed at. Its time rides with it: free text, truncated
|
|
389
|
+
// like a body, and meaningless without a status to qualify.
|
|
390
|
+
const status = src.status;
|
|
391
|
+
if (status === "delivered" || status === "read") {
|
|
392
|
+
msg.status = status;
|
|
393
|
+
if (typeof src.statusAt === "string" && src.statusAt !== "") {
|
|
394
|
+
msg.statusAt = truncateUtf8(src.statusAt).value;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
361
397
|
return msg;
|
|
362
398
|
}
|
|
363
399
|
|
|
400
|
+
/** The block's `typing`: `self` or `other`, else nobody. The thread's
|
|
401
|
+
* present tense is a decoration, not a turn, so a value that isn't one of
|
|
402
|
+
* the two draws nothing rather than guessing at who. */
|
|
403
|
+
function typingOf(v: unknown): "self" | "other" | undefined {
|
|
404
|
+
return v === "self" || v === "other" ? v : undefined;
|
|
405
|
+
}
|
|
406
|
+
|
|
364
407
|
/** Reactions get the tolerance `side` gets: an entry that isn't quite right
|
|
365
408
|
* is drawn rather than dropped — a `from` that is missing or misspelled is
|
|
366
409
|
* the other party's — and only one with nothing to draw (no string
|
package/src/recorder.ts
CHANGED
|
@@ -882,7 +882,9 @@ export type StepBlock =
|
|
|
882
882
|
| { type: "kv"; rows: { label: string; value?: string; error?: boolean }[]; label?: string }
|
|
883
883
|
| { type: "table"; columns: string[]; rows: unknown[][] }
|
|
884
884
|
| { type: "htmlSandbox"; html: string; label?: string }
|
|
885
|
-
|
|
885
|
+
// `typing` is the thread's present tense: whoever is writing right now
|
|
886
|
+
// gets the three-dot bubble at the end of the thread, on their side.
|
|
887
|
+
| { type: "chat"; messages: ChatBlockMessage[]; label?: string; typing?: "self" | "other" }
|
|
886
888
|
// A disclosure. For what a step has but a reader does not want on every
|
|
887
889
|
// step — a connection's protocol version, session id, redacted token.
|
|
888
890
|
| { type: "details"; summary: string; blocks: StepBlock[] };
|
|
@@ -902,6 +904,14 @@ export interface ChatBlockMessage {
|
|
|
902
904
|
* corner, on the side opposite the sender. Absent and empty read the
|
|
903
905
|
* same: nothing to draw. */
|
|
904
906
|
reactions?: ChatBlockReaction[];
|
|
907
|
+
/** Delivery status — the caption a messaging app shows under a bubble
|
|
908
|
+
* ("Delivered", "Read"), on the bubble's own side. Absent draws
|
|
909
|
+
* nothing. */
|
|
910
|
+
status?: "delivered" | "read";
|
|
911
|
+
/** When the status happened, as the app would show it ("23:14"): free
|
|
912
|
+
* text drawn after the status caption ("Read 23:14"). Rides with
|
|
913
|
+
* `status`; alone it has nothing to qualify. */
|
|
914
|
+
statusAt?: string;
|
|
905
915
|
}
|
|
906
916
|
|
|
907
917
|
/** One reaction on a {@link ChatBlockMessage}. */
|