@newbase-clawchat/openclaw-clawchat 2026.4.15
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 +112 -0
- package/index.ts +19 -0
- package/openclaw.plugin.json +52 -0
- package/package.json +58 -0
- package/src/api-client.test.ts +325 -0
- package/src/api-client.ts +225 -0
- package/src/api-types.ts +71 -0
- package/src/buffered-stream.test.ts +201 -0
- package/src/buffered-stream.ts +206 -0
- package/src/channel.test.ts +72 -0
- package/src/channel.ts +278 -0
- package/src/client.test.ts +174 -0
- package/src/client.ts +279 -0
- package/src/config.test.ts +110 -0
- package/src/config.ts +277 -0
- package/src/inbound.test.ts +264 -0
- package/src/inbound.ts +201 -0
- package/src/login.runtime.test.ts +257 -0
- package/src/login.runtime.ts +153 -0
- package/src/manifest.test.ts +22 -0
- package/src/media-runtime.test.ts +159 -0
- package/src/media-runtime.ts +143 -0
- package/src/message-mapper.test.ts +131 -0
- package/src/message-mapper.ts +82 -0
- package/src/outbound.test.ts +244 -0
- package/src/outbound.ts +141 -0
- package/src/protocol.test.ts +42 -0
- package/src/protocol.ts +38 -0
- package/src/reply-dispatcher.ts +387 -0
- package/src/runtime.test.ts +276 -0
- package/src/runtime.ts +316 -0
- package/src/streaming.test.ts +116 -0
- package/src/streaming.ts +89 -0
- package/src/tools-schema.ts +45 -0
- package/src/tools.test.ts +135 -0
- package/src/tools.ts +308 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ClawlingApiError,
|
|
3
|
+
type AgentConnectResult,
|
|
4
|
+
type FriendList,
|
|
5
|
+
type Profile,
|
|
6
|
+
type UploadResult,
|
|
7
|
+
} from "./api-types.ts";
|
|
8
|
+
import { CHANNEL_ID } from "./config.ts";
|
|
9
|
+
|
|
10
|
+
export interface ApiClientOptions {
|
|
11
|
+
baseUrl: string;
|
|
12
|
+
token: string;
|
|
13
|
+
/**
|
|
14
|
+
* Logged-in agent's `user_id`. Required for `updateMyProfile`, which
|
|
15
|
+
* targets `/v1/agents/{userId}`. Safe to omit for unauthenticated /
|
|
16
|
+
* pre-login calls (e.g. `agentsConnect`).
|
|
17
|
+
*/
|
|
18
|
+
userId?: string;
|
|
19
|
+
/** Test override only. Defaults to global `fetch`. */
|
|
20
|
+
fetchImpl?: typeof fetch;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface OpenclawClawlingApiClient {
|
|
24
|
+
getMyProfile(): Promise<Profile>;
|
|
25
|
+
getUserInfo(userId: string): Promise<Profile>;
|
|
26
|
+
listFriends(params: { page?: number; pageSize?: number }): Promise<FriendList>;
|
|
27
|
+
updateMyProfile(patch: { nick_name?: string; avatar?: string }): Promise<Profile>;
|
|
28
|
+
uploadMedia(params: { buffer: Buffer; filename: string; mime?: string }): Promise<UploadResult>;
|
|
29
|
+
/**
|
|
30
|
+
* Exchange an invite code for an agent token.
|
|
31
|
+
* Request body shape: `{ code, platform, type }`.
|
|
32
|
+
*/
|
|
33
|
+
agentsConnect(params: {
|
|
34
|
+
/** The invite code entered by the operator. */
|
|
35
|
+
inviteCode: string;
|
|
36
|
+
/** Platform the agent is attaching from (e.g. "openclaw"). */
|
|
37
|
+
platform: string;
|
|
38
|
+
/** Agent type tag (e.g. "bot"). */
|
|
39
|
+
type: string;
|
|
40
|
+
}): Promise<AgentConnectResult>;
|
|
41
|
+
/**
|
|
42
|
+
* Upload an avatar image via `POST /v1/files/upload-url`. Returns the
|
|
43
|
+
* same `UploadResult` shape as `uploadMedia` — the resulting `url` is
|
|
44
|
+
* what you then pass to `updateMyProfile({ avatar: url })`.
|
|
45
|
+
*/
|
|
46
|
+
uploadAvatar(params: {
|
|
47
|
+
buffer: Buffer;
|
|
48
|
+
filename: string;
|
|
49
|
+
mime?: string;
|
|
50
|
+
}): Promise<UploadResult>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function createOpenclawClawlingApiClient(opts: ApiClientOptions): OpenclawClawlingApiClient {
|
|
54
|
+
if (!/^https?:\/\//i.test(opts.baseUrl)) {
|
|
55
|
+
throw new ClawlingApiError(
|
|
56
|
+
"validation",
|
|
57
|
+
`openclaw-clawchat baseUrl must start with http:// or https:// (got "${opts.baseUrl}")`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
const baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
61
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
62
|
+
|
|
63
|
+
function url(path: string): string {
|
|
64
|
+
return `${baseUrl}${path}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function authHeaders(extra: Record<string, string> = {}): Record<string, string> {
|
|
68
|
+
// `X-Device-Id` is sent on every request so the server can correlate
|
|
69
|
+
// activity back to this plugin instance. Callers may override via
|
|
70
|
+
// `extra` (e.g. tests) but the default is the channel id.
|
|
71
|
+
return {
|
|
72
|
+
authorization: `Bearer ${opts.token}`,
|
|
73
|
+
"x-device-id": CHANNEL_ID,
|
|
74
|
+
...extra,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function readEnvelope<T>(res: Response, path: string): Promise<T> {
|
|
79
|
+
if (res.status === 401 || res.status === 403) {
|
|
80
|
+
throw new ClawlingApiError("auth", `unauthorized (status ${res.status})`, {
|
|
81
|
+
status: res.status,
|
|
82
|
+
path,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (!res.ok) {
|
|
86
|
+
const snippet = await res.text().catch(() => "");
|
|
87
|
+
throw new ClawlingApiError("transport", `http ${res.status} ${snippet.slice(0, 200)}`, {
|
|
88
|
+
status: res.status,
|
|
89
|
+
path,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
let parsed: unknown;
|
|
93
|
+
try {
|
|
94
|
+
parsed = await res.json();
|
|
95
|
+
} catch (err) {
|
|
96
|
+
throw new ClawlingApiError(
|
|
97
|
+
"transport",
|
|
98
|
+
`non-JSON response: ${err instanceof Error ? err.message : String(err)}`,
|
|
99
|
+
{ status: res.status, path },
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
// Unified envelope: `{ code: number, msg: string, data: T }`.
|
|
103
|
+
// `code === 0` means success; any other value is a business error whose
|
|
104
|
+
// `msg` is surfaced to callers and `code` is preserved on the error meta.
|
|
105
|
+
const env = parsed as { code?: unknown; msg?: unknown; data?: T };
|
|
106
|
+
const code = typeof env.code === "number" ? env.code : Number.NaN;
|
|
107
|
+
const msg = typeof env.msg === "string" ? env.msg : "";
|
|
108
|
+
if (!Number.isFinite(code)) {
|
|
109
|
+
throw new ClawlingApiError("transport", "invalid envelope: missing numeric `code`", {
|
|
110
|
+
status: res.status,
|
|
111
|
+
path,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (code !== 0) {
|
|
115
|
+
throw new ClawlingApiError("api", msg || `code=${code}`, {
|
|
116
|
+
code,
|
|
117
|
+
status: res.status,
|
|
118
|
+
path,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return env.data as T;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function call<T>(
|
|
125
|
+
method: string,
|
|
126
|
+
path: string,
|
|
127
|
+
init?: { body?: unknown; headers?: Record<string, string> },
|
|
128
|
+
): Promise<T> {
|
|
129
|
+
let res: Response;
|
|
130
|
+
try {
|
|
131
|
+
const requestInit: RequestInit = {
|
|
132
|
+
method,
|
|
133
|
+
headers: authHeaders(init?.headers),
|
|
134
|
+
};
|
|
135
|
+
if (init?.body !== undefined) {
|
|
136
|
+
requestInit.body = init.body as BodyInit;
|
|
137
|
+
}
|
|
138
|
+
res = await fetchImpl(url(path), requestInit);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
throw new ClawlingApiError(
|
|
141
|
+
"transport",
|
|
142
|
+
`fetch failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
143
|
+
{ path },
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return await readEnvelope<T>(res, path);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// All JSON API endpoints live under `/v1/...`. Media upload is the one
|
|
150
|
+
// intentional exception — the upstream server mounts it at `/media/upload`
|
|
151
|
+
// without the version prefix.
|
|
152
|
+
return {
|
|
153
|
+
async getMyProfile(): Promise<Profile> {
|
|
154
|
+
return await call<Profile>("GET", "/v1/users/me");
|
|
155
|
+
},
|
|
156
|
+
async getUserInfo(userId: string): Promise<Profile> {
|
|
157
|
+
return await call<Profile>("GET", `/v1/users/${encodeURIComponent(userId)}`);
|
|
158
|
+
},
|
|
159
|
+
async listFriends(params): Promise<FriendList> {
|
|
160
|
+
const sp = new URLSearchParams();
|
|
161
|
+
if (typeof params.page === "number") sp.set("page", String(params.page));
|
|
162
|
+
if (typeof params.pageSize === "number") sp.set("pageSize", String(params.pageSize));
|
|
163
|
+
const q = sp.toString();
|
|
164
|
+
return await call<FriendList>("GET", q ? `/v1/friends?${q}` : "/v1/friends");
|
|
165
|
+
},
|
|
166
|
+
async updateMyProfile(patch): Promise<Profile> {
|
|
167
|
+
if (!opts.userId?.trim()) {
|
|
168
|
+
throw new ClawlingApiError(
|
|
169
|
+
"validation",
|
|
170
|
+
"updateMyProfile: userId is required to target /v1/agents/{userId}",
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return await call<Profile>(
|
|
174
|
+
"PATCH",
|
|
175
|
+
`/v1/agents/${encodeURIComponent(opts.userId.trim())}`,
|
|
176
|
+
{
|
|
177
|
+
body: JSON.stringify(patch),
|
|
178
|
+
headers: { "content-type": "application/json" },
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
},
|
|
182
|
+
async agentsConnect({ inviteCode, platform, type }): Promise<AgentConnectResult> {
|
|
183
|
+
if (!inviteCode?.trim()) {
|
|
184
|
+
throw new ClawlingApiError("validation", "agentsConnect: inviteCode is required");
|
|
185
|
+
}
|
|
186
|
+
if (!platform?.trim()) {
|
|
187
|
+
throw new ClawlingApiError("validation", "agentsConnect: platform is required");
|
|
188
|
+
}
|
|
189
|
+
if (!type?.trim()) {
|
|
190
|
+
throw new ClawlingApiError("validation", "agentsConnect: type is required");
|
|
191
|
+
}
|
|
192
|
+
return await call<AgentConnectResult>("POST", "/v1/agents/connect", {
|
|
193
|
+
// `X-Device-Id` is added globally via `authHeaders` on every request.
|
|
194
|
+
headers: { "content-type": "application/json" },
|
|
195
|
+
body: JSON.stringify({
|
|
196
|
+
code: inviteCode.trim(),
|
|
197
|
+
platform: platform.trim(),
|
|
198
|
+
type: type.trim(),
|
|
199
|
+
}),
|
|
200
|
+
});
|
|
201
|
+
},
|
|
202
|
+
async uploadMedia(params): Promise<UploadResult> {
|
|
203
|
+
const blob = new Blob([new Uint8Array(params.buffer)], {
|
|
204
|
+
type: params.mime ?? "application/octet-stream",
|
|
205
|
+
});
|
|
206
|
+
const file = new File([blob], params.filename, {
|
|
207
|
+
type: params.mime ?? "application/octet-stream",
|
|
208
|
+
});
|
|
209
|
+
const fd = new FormData();
|
|
210
|
+
fd.set("file", file);
|
|
211
|
+
return await call<UploadResult>("POST", "/media/upload", { body: fd });
|
|
212
|
+
},
|
|
213
|
+
async uploadAvatar(params): Promise<UploadResult> {
|
|
214
|
+
const blob = new Blob([new Uint8Array(params.buffer)], {
|
|
215
|
+
type: params.mime ?? "application/octet-stream",
|
|
216
|
+
});
|
|
217
|
+
const file = new File([blob], params.filename, {
|
|
218
|
+
type: params.mime ?? "application/octet-stream",
|
|
219
|
+
});
|
|
220
|
+
const fd = new FormData();
|
|
221
|
+
fd.set("file", file);
|
|
222
|
+
return await call<UploadResult>("POST", "/v1/files/upload-url", { body: fd });
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
package/src/api-types.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public-facing types for the Clawling Chat HTTP API.
|
|
3
|
+
*
|
|
4
|
+
* Field names mirror the upstream API (snake_case) so we don't lose
|
|
5
|
+
* fidelity on responses. Tool schemas accept camelCase externally and
|
|
6
|
+
* translate at the api-client boundary.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface Profile {
|
|
10
|
+
user_id: string;
|
|
11
|
+
nick_name: string;
|
|
12
|
+
avatar?: string;
|
|
13
|
+
bio?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface FriendList {
|
|
17
|
+
items: Profile[];
|
|
18
|
+
total?: number;
|
|
19
|
+
page: number;
|
|
20
|
+
pageSize: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface UploadResult {
|
|
24
|
+
url: string;
|
|
25
|
+
size: number;
|
|
26
|
+
mime: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AgentProfile {
|
|
30
|
+
id: string;
|
|
31
|
+
owner_id: string;
|
|
32
|
+
user_id: string;
|
|
33
|
+
type: string;
|
|
34
|
+
nickname: string;
|
|
35
|
+
avatar_url: string;
|
|
36
|
+
bio: string;
|
|
37
|
+
visibility: string;
|
|
38
|
+
status: string;
|
|
39
|
+
platform: string;
|
|
40
|
+
created_at: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Response payload for `POST /v1/agents/connect` — the endpoint that exchanges
|
|
45
|
+
* an invite code for the credentials this account uses to open the
|
|
46
|
+
* streaming WebSocket. `access_token` is the bearer this agent uses for
|
|
47
|
+
* API + WebSocket auth; `agent.user_id` is the stable id the streaming
|
|
48
|
+
* protocol routes on.
|
|
49
|
+
*/
|
|
50
|
+
export interface AgentConnectResult {
|
|
51
|
+
agent: AgentProfile;
|
|
52
|
+
access_token: string;
|
|
53
|
+
refresh_token: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type ClawlingApiErrorKind =
|
|
57
|
+
| "auth" // 401 / 403 — token bad or expired
|
|
58
|
+
| "api" // server-returned non-zero `code`
|
|
59
|
+
| "transport" // network failure / 5xx / non-JSON body
|
|
60
|
+
| "validation"; // local pre-check failure (e.g., file too large)
|
|
61
|
+
|
|
62
|
+
export class ClawlingApiError extends Error {
|
|
63
|
+
constructor(
|
|
64
|
+
public readonly kind: ClawlingApiErrorKind,
|
|
65
|
+
message: string,
|
|
66
|
+
public readonly meta?: { code?: number; status?: number; path?: string },
|
|
67
|
+
) {
|
|
68
|
+
super(message);
|
|
69
|
+
this.name = "ClawlingApiError";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { ClawlingChatClient } from "@newbase-clawchat/sdk";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { mergeStreamingText, openBufferedStreamingSession } from "./buffered-stream.ts";
|
|
4
|
+
|
|
5
|
+
type Emitted = { event: string; payload: Record<string, unknown> };
|
|
6
|
+
|
|
7
|
+
function mockClient() {
|
|
8
|
+
const sent: Emitted[] = [];
|
|
9
|
+
const typing: Array<[unknown, boolean]> = [];
|
|
10
|
+
const client = {
|
|
11
|
+
emitRaw: vi.fn((event: string, payload: Record<string, unknown>) => {
|
|
12
|
+
sent.push({ event, payload });
|
|
13
|
+
}),
|
|
14
|
+
typing: vi.fn((to: unknown, flag: boolean) => {
|
|
15
|
+
typing.push([to, flag]);
|
|
16
|
+
}),
|
|
17
|
+
} as unknown as ClawlingChatClient;
|
|
18
|
+
return { client, sent, typing };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("mergeStreamingText", () => {
|
|
22
|
+
it("returns incoming when base empty", () => {
|
|
23
|
+
expect(mergeStreamingText("", "hi")).toBe("hi");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("returns incoming when incoming is a prefix-extension of base", () => {
|
|
27
|
+
expect(mergeStreamingText("Hel", "Hello")).toBe("Hello");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns base when incoming is a substring of base", () => {
|
|
31
|
+
expect(mergeStreamingText("Hello, world", "Hello")).toBe("Hello, world");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("joins at overlap when neither is a prefix", () => {
|
|
35
|
+
expect(mergeStreamingText("Hello w", "world")).toBe("Hello world");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("concatenates when no overlap", () => {
|
|
39
|
+
expect(mergeStreamingText("abc", "xyz")).toBe("abcxyz");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("openBufferedStreamingSession", () => {
|
|
44
|
+
it("emits typing(true) + created immediately on open", () => {
|
|
45
|
+
const { client, sent, typing } = mockClient();
|
|
46
|
+
openBufferedStreamingSession({
|
|
47
|
+
client,
|
|
48
|
+
to: { id: "u1", type: "direct" },
|
|
49
|
+
sender: { sender_id: "a1", type: "direct", display_name: "Bot" },
|
|
50
|
+
messageId: "m1",
|
|
51
|
+
flushIntervalMs: 50,
|
|
52
|
+
minChunkChars: 4,
|
|
53
|
+
maxBufferChars: 1000,
|
|
54
|
+
});
|
|
55
|
+
expect(typing).toEqual([[{ id: "u1", type: "direct" }, true]]);
|
|
56
|
+
expect(sent.map((s) => s.event)).toEqual(["message.created"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("flushes as message.add with { text: cumulative, delta: new } on threshold", async () => {
|
|
60
|
+
const { client, sent } = mockClient();
|
|
61
|
+
const session = openBufferedStreamingSession({
|
|
62
|
+
client,
|
|
63
|
+
to: { id: "u1", type: "direct" },
|
|
64
|
+
sender: { sender_id: "a1", type: "direct", display_name: "Bot" },
|
|
65
|
+
messageId: "m1",
|
|
66
|
+
flushIntervalMs: 60_000,
|
|
67
|
+
minChunkChars: 4,
|
|
68
|
+
maxBufferChars: 1000,
|
|
69
|
+
});
|
|
70
|
+
// 3 chars — below threshold, should NOT flush yet
|
|
71
|
+
await session.queueSnapshot("abc");
|
|
72
|
+
expect(sent.filter((s) => s.event === "message.add")).toHaveLength(0);
|
|
73
|
+
// Reaching 6 chars (>= 4 chars of unflushed delta) should flush
|
|
74
|
+
await session.queueSnapshot("abcdef");
|
|
75
|
+
const adds = sent.filter((s) => s.event === "message.add");
|
|
76
|
+
expect(adds).toHaveLength(1);
|
|
77
|
+
expect((adds[0]!.payload as { fragments: unknown }).fragments).toEqual([
|
|
78
|
+
{ kind: "text", text: "abcdef", delta: "abcdef" },
|
|
79
|
+
]);
|
|
80
|
+
expect(session.flushedText).toBe("abcdef");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("queueDelta carries cumulative text with per-frame delta", async () => {
|
|
84
|
+
const { client, sent } = mockClient();
|
|
85
|
+
const session = openBufferedStreamingSession({
|
|
86
|
+
client,
|
|
87
|
+
to: { id: "u1", type: "direct" },
|
|
88
|
+
sender: { sender_id: "a1", type: "direct", display_name: "Bot" },
|
|
89
|
+
messageId: "m1",
|
|
90
|
+
flushIntervalMs: 60_000,
|
|
91
|
+
minChunkChars: 1,
|
|
92
|
+
maxBufferChars: 1000,
|
|
93
|
+
});
|
|
94
|
+
await session.queueDelta("Hello ");
|
|
95
|
+
await session.queueDelta("world");
|
|
96
|
+
const adds = sent.filter((s) => s.event === "message.add");
|
|
97
|
+
const fragments = adds.map(
|
|
98
|
+
(a) => (a.payload as { fragments: Array<{ kind: string; text: string; delta: string }> }).fragments,
|
|
99
|
+
);
|
|
100
|
+
expect(fragments).toEqual([
|
|
101
|
+
[{ kind: "text", text: "Hello ", delta: "Hello " }],
|
|
102
|
+
[{ kind: "text", text: "Hello world", delta: "world" }],
|
|
103
|
+
]);
|
|
104
|
+
expect(session.currentText).toBe("Hello world");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("done() flushes remaining buffer, emits message.done and typing(false)", async () => {
|
|
108
|
+
const { client, sent, typing } = mockClient();
|
|
109
|
+
const session = openBufferedStreamingSession({
|
|
110
|
+
client,
|
|
111
|
+
to: { id: "u1", type: "direct" },
|
|
112
|
+
sender: { sender_id: "a1", type: "direct", display_name: "Bot" },
|
|
113
|
+
messageId: "m1",
|
|
114
|
+
flushIntervalMs: 60_000,
|
|
115
|
+
minChunkChars: 999,
|
|
116
|
+
maxBufferChars: 999,
|
|
117
|
+
});
|
|
118
|
+
await session.queueSnapshot("Hello, world");
|
|
119
|
+
// No add yet (below threshold, no timer fired)
|
|
120
|
+
expect(sent.filter((s) => s.event === "message.add")).toHaveLength(0);
|
|
121
|
+
await session.done();
|
|
122
|
+
const events = sent.map((s) => s.event);
|
|
123
|
+
expect(events).toEqual(["message.created", "message.add", "message.done"]);
|
|
124
|
+
expect(typing).toEqual([
|
|
125
|
+
[{ id: "u1", type: "direct" }, true],
|
|
126
|
+
[{ id: "u1", type: "direct" }, false],
|
|
127
|
+
]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("done() is idempotent", async () => {
|
|
131
|
+
const { client, sent } = mockClient();
|
|
132
|
+
const session = openBufferedStreamingSession({
|
|
133
|
+
client,
|
|
134
|
+
to: { id: "u1", type: "direct" },
|
|
135
|
+
sender: { sender_id: "a1", type: "direct", display_name: "Bot" },
|
|
136
|
+
messageId: "m1",
|
|
137
|
+
flushIntervalMs: 60_000,
|
|
138
|
+
minChunkChars: 999,
|
|
139
|
+
maxBufferChars: 999,
|
|
140
|
+
});
|
|
141
|
+
await session.done();
|
|
142
|
+
await session.done();
|
|
143
|
+
const doneCount = sent.filter((s) => s.event === "message.done").length;
|
|
144
|
+
expect(doneCount).toBe(1);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("fail() emits message.failed with reason and typing(false)", async () => {
|
|
148
|
+
const { client, sent, typing } = mockClient();
|
|
149
|
+
const session = openBufferedStreamingSession({
|
|
150
|
+
client,
|
|
151
|
+
to: { id: "u1", type: "direct" },
|
|
152
|
+
sender: { sender_id: "a1", type: "direct", display_name: "Bot" },
|
|
153
|
+
messageId: "m1",
|
|
154
|
+
flushIntervalMs: 60_000,
|
|
155
|
+
minChunkChars: 999,
|
|
156
|
+
maxBufferChars: 999,
|
|
157
|
+
});
|
|
158
|
+
await session.fail("boom");
|
|
159
|
+
const failed = sent.find((s) => s.event === "message.failed")!;
|
|
160
|
+
expect(failed.payload.reason).toBe("boom");
|
|
161
|
+
expect(typing.at(-1)).toEqual([{ id: "u1", type: "direct" }, false]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("deduplicates a snapshot that is a substring of the buffered snapshot", async () => {
|
|
165
|
+
const { client, sent } = mockClient();
|
|
166
|
+
const session = openBufferedStreamingSession({
|
|
167
|
+
client,
|
|
168
|
+
to: { id: "u1", type: "direct" },
|
|
169
|
+
sender: { sender_id: "a1", type: "direct", display_name: "Bot" },
|
|
170
|
+
messageId: "m1",
|
|
171
|
+
flushIntervalMs: 60_000,
|
|
172
|
+
minChunkChars: 1,
|
|
173
|
+
maxBufferChars: 1000,
|
|
174
|
+
});
|
|
175
|
+
await session.queueSnapshot("Hello, world");
|
|
176
|
+
await session.queueSnapshot("Hello"); // substring — no-op
|
|
177
|
+
const adds = sent.filter((s) => s.event === "message.add");
|
|
178
|
+
expect(adds).toHaveLength(1);
|
|
179
|
+
expect(session.currentText).toBe("Hello, world");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("done() carries fragments: [{ text: finalMerged }] on message.done", async () => {
|
|
183
|
+
const { client, sent } = mockClient();
|
|
184
|
+
const session = openBufferedStreamingSession({
|
|
185
|
+
client,
|
|
186
|
+
to: { id: "u1", type: "direct" },
|
|
187
|
+
sender: { sender_id: "a1", type: "direct", display_name: "Bot" },
|
|
188
|
+
messageId: "m1",
|
|
189
|
+
flushIntervalMs: 60_000,
|
|
190
|
+
minChunkChars: 1,
|
|
191
|
+
maxBufferChars: 1000,
|
|
192
|
+
});
|
|
193
|
+
await session.queueDelta("Hel");
|
|
194
|
+
await session.queueDelta("lo");
|
|
195
|
+
await session.done();
|
|
196
|
+
const doneFrame = sent.find((s) => s.event === "message.done")!;
|
|
197
|
+
expect((doneFrame.payload as { fragments: unknown }).fragments).toEqual([
|
|
198
|
+
{ kind: "text", text: "Hello" },
|
|
199
|
+
]);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import type { ClawlingChatClient } from "@newbase-clawchat/sdk";
|
|
2
|
+
import {
|
|
3
|
+
emitStreamAdd,
|
|
4
|
+
emitStreamCreated,
|
|
5
|
+
emitStreamDone,
|
|
6
|
+
emitStreamFailed,
|
|
7
|
+
type EnvelopeRouting,
|
|
8
|
+
type StreamSender,
|
|
9
|
+
} from "./client.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Merge two views of the same progressively-revealed text.
|
|
13
|
+
*
|
|
14
|
+
* The agent runner may give us either:
|
|
15
|
+
* - full snapshots ("Hel", "Hello", "Hello, world") where each item is
|
|
16
|
+
* a superset of the previous; or
|
|
17
|
+
* - overlapping slices ("hello ", "world hello ") that don't share a
|
|
18
|
+
* prefix but share an overlap at the join.
|
|
19
|
+
*
|
|
20
|
+
* This helper returns a longest-sensible combined string. Ported from
|
|
21
|
+
* `clawling-channel/src/reply-dispatcher.ts`.
|
|
22
|
+
*/
|
|
23
|
+
export function mergeStreamingText(
|
|
24
|
+
previousText: string | undefined,
|
|
25
|
+
nextText: string | undefined,
|
|
26
|
+
): string {
|
|
27
|
+
const currentSnapshot = typeof previousText === "string" ? previousText : "";
|
|
28
|
+
const incomingText = typeof nextText === "string" ? nextText : "";
|
|
29
|
+
if (!incomingText) return currentSnapshot;
|
|
30
|
+
if (!currentSnapshot || incomingText === currentSnapshot) return incomingText;
|
|
31
|
+
if (incomingText.startsWith(currentSnapshot)) return incomingText;
|
|
32
|
+
if (currentSnapshot.startsWith(incomingText)) return currentSnapshot;
|
|
33
|
+
if (incomingText.includes(currentSnapshot)) return incomingText;
|
|
34
|
+
if (currentSnapshot.includes(incomingText)) return currentSnapshot;
|
|
35
|
+
const maxOverlap = Math.min(currentSnapshot.length, incomingText.length);
|
|
36
|
+
for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
|
|
37
|
+
if (currentSnapshot.slice(-overlap) === incomingText.slice(0, overlap)) {
|
|
38
|
+
return `${currentSnapshot}${incomingText.slice(overlap)}`;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return `${currentSnapshot}${incomingText}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface BufferedStreamOptions {
|
|
45
|
+
client: ClawlingChatClient;
|
|
46
|
+
routing: EnvelopeRouting;
|
|
47
|
+
sender: StreamSender;
|
|
48
|
+
messageId: string;
|
|
49
|
+
flushIntervalMs: number;
|
|
50
|
+
minChunkChars: number;
|
|
51
|
+
maxBufferChars: number;
|
|
52
|
+
/** Emit typing(true/false) around the stream lifecycle. Default: true. */
|
|
53
|
+
emitTyping?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface BufferedStreamSession {
|
|
57
|
+
/** The full accumulated text (even if not yet flushed to the wire). */
|
|
58
|
+
readonly currentText: string;
|
|
59
|
+
/** The last flushed snapshot (sent via message.add events so far). */
|
|
60
|
+
readonly flushedText: string;
|
|
61
|
+
/** Replace the snapshot with a longer-or-equal version. */
|
|
62
|
+
queueSnapshot: (snapshot: string) => Promise<void>;
|
|
63
|
+
/** Append `delta` to the running snapshot. */
|
|
64
|
+
queueDelta: (delta: string) => Promise<void>;
|
|
65
|
+
/** Force any buffered text to be emitted as message.add before resolving. */
|
|
66
|
+
flush: () => Promise<void>;
|
|
67
|
+
/** Emit message.done once (idempotent) and typing(false). */
|
|
68
|
+
done: () => Promise<void>;
|
|
69
|
+
/** Emit message.failed and typing(false). */
|
|
70
|
+
fail: (reason?: string) => Promise<void>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build a streaming session wrapper around message.created/add/done events.
|
|
75
|
+
*
|
|
76
|
+
* Usage pattern (matching clawling-channel):
|
|
77
|
+
* const session = openBufferedStreamingSession({...});
|
|
78
|
+
* await session.queueSnapshot("Hel");
|
|
79
|
+
* await session.queueSnapshot("Hello");
|
|
80
|
+
* await session.queueDelta(", world");
|
|
81
|
+
* await session.done();
|
|
82
|
+
*/
|
|
83
|
+
export function openBufferedStreamingSession(
|
|
84
|
+
options: BufferedStreamOptions,
|
|
85
|
+
): BufferedStreamSession {
|
|
86
|
+
const emitTyping = options.emitTyping !== false;
|
|
87
|
+
if (emitTyping)
|
|
88
|
+
options.client.typing(options.routing.chatId, true, options.routing.chatType);
|
|
89
|
+
emitStreamCreated(options.client, {
|
|
90
|
+
messageId: options.messageId,
|
|
91
|
+
routing: options.routing,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
let bufferedSnapshot = "";
|
|
95
|
+
let flushedSnapshot = "";
|
|
96
|
+
let sequence = 0;
|
|
97
|
+
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
98
|
+
let pendingFlush: Promise<void> = Promise.resolve();
|
|
99
|
+
let closed = false;
|
|
100
|
+
|
|
101
|
+
const clearTimer = () => {
|
|
102
|
+
if (flushTimer) {
|
|
103
|
+
clearTimeout(flushTimer);
|
|
104
|
+
flushTimer = null;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const performFlush = async () => {
|
|
109
|
+
clearTimer();
|
|
110
|
+
if (closed) return;
|
|
111
|
+
if (bufferedSnapshot === flushedSnapshot) return;
|
|
112
|
+
const snapshot = bufferedSnapshot;
|
|
113
|
+
const delta = snapshot.slice(flushedSnapshot.length);
|
|
114
|
+
if (!delta) return;
|
|
115
|
+
sequence += 1;
|
|
116
|
+
emitStreamAdd(options.client, {
|
|
117
|
+
messageId: options.messageId,
|
|
118
|
+
routing: options.routing,
|
|
119
|
+
sequence,
|
|
120
|
+
fullText: snapshot,
|
|
121
|
+
textDelta: delta,
|
|
122
|
+
});
|
|
123
|
+
flushedSnapshot = snapshot;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const flush = async (): Promise<void> => {
|
|
127
|
+
pendingFlush = pendingFlush.then(performFlush);
|
|
128
|
+
await pendingFlush;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const scheduleFlush = () => {
|
|
132
|
+
if (flushTimer || closed) return;
|
|
133
|
+
flushTimer = setTimeout(() => {
|
|
134
|
+
flushTimer = null;
|
|
135
|
+
void flush();
|
|
136
|
+
}, options.flushIntervalMs);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const queueSnapshot = async (snapshot: string): Promise<void> => {
|
|
140
|
+
if (closed || !snapshot) return;
|
|
141
|
+
const base = bufferedSnapshot.length >= flushedSnapshot.length ? bufferedSnapshot : flushedSnapshot;
|
|
142
|
+
const merged = mergeStreamingText(base, snapshot);
|
|
143
|
+
if (merged === bufferedSnapshot) return;
|
|
144
|
+
bufferedSnapshot = merged;
|
|
145
|
+
const deltaChars = Math.max(0, bufferedSnapshot.length - flushedSnapshot.length);
|
|
146
|
+
if (deltaChars >= options.minChunkChars || bufferedSnapshot.length >= options.maxBufferChars) {
|
|
147
|
+
await flush();
|
|
148
|
+
} else {
|
|
149
|
+
scheduleFlush();
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const queueDelta = async (delta: string): Promise<void> => {
|
|
154
|
+
if (closed || !delta) return;
|
|
155
|
+
bufferedSnapshot = `${bufferedSnapshot}${delta}`;
|
|
156
|
+
const deltaChars = Math.max(0, bufferedSnapshot.length - flushedSnapshot.length);
|
|
157
|
+
if (deltaChars >= options.minChunkChars || bufferedSnapshot.length >= options.maxBufferChars) {
|
|
158
|
+
await flush();
|
|
159
|
+
} else {
|
|
160
|
+
scheduleFlush();
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const done = async (): Promise<void> => {
|
|
165
|
+
if (closed) return;
|
|
166
|
+
await flush();
|
|
167
|
+
closed = true;
|
|
168
|
+
clearTimer();
|
|
169
|
+
emitStreamDone(options.client, {
|
|
170
|
+
messageId: options.messageId,
|
|
171
|
+
routing: options.routing,
|
|
172
|
+
finalSequence: sequence,
|
|
173
|
+
finalText: bufferedSnapshot,
|
|
174
|
+
});
|
|
175
|
+
if (emitTyping)
|
|
176
|
+
options.client.typing(options.routing.chatId, false, options.routing.chatType);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const fail = async (reason?: string): Promise<void> => {
|
|
180
|
+
if (closed) return;
|
|
181
|
+
closed = true;
|
|
182
|
+
clearTimer();
|
|
183
|
+
emitStreamFailed(options.client, {
|
|
184
|
+
messageId: options.messageId,
|
|
185
|
+
routing: options.routing,
|
|
186
|
+
sequence: sequence + 1,
|
|
187
|
+
...(reason !== undefined ? { reason } : {}),
|
|
188
|
+
});
|
|
189
|
+
if (emitTyping)
|
|
190
|
+
options.client.typing(options.routing.chatId, false, options.routing.chatType);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
get currentText() {
|
|
195
|
+
return bufferedSnapshot;
|
|
196
|
+
},
|
|
197
|
+
get flushedText() {
|
|
198
|
+
return flushedSnapshot;
|
|
199
|
+
},
|
|
200
|
+
queueSnapshot,
|
|
201
|
+
queueDelta,
|
|
202
|
+
flush,
|
|
203
|
+
done,
|
|
204
|
+
fail,
|
|
205
|
+
};
|
|
206
|
+
}
|