@openfinclaw/findoo-alpha-plugin 2026.3.12
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/index.ts +115 -0
- package/openclaw.plugin.json +42 -0
- package/package.json +32 -0
- package/skills/findoo-analyze/skill.md +53 -0
- package/src/a2a-client.ts +287 -0
- package/src/config.ts +60 -0
- package/src/pending-task-tracker.ts +254 -0
- package/src/register-tools.ts +145 -0
- package/test/a2a-client.test.ts +112 -0
- package/test/a2a-live.test.ts +198 -0
- package/test/pending-task-tracker.test.ts +287 -0
- package/test/plugin-register.test.ts +101 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L2 — Findoo A2A Live Integration Test
|
|
3
|
+
*
|
|
4
|
+
* Tests real A2A communication with the remote strategy-agent (43.128.100.43:5085).
|
|
5
|
+
* Requires network access to PRD server.
|
|
6
|
+
*
|
|
7
|
+
* Run: LIVE=1 npx vitest run extensions/findoo-alpha-plugin/test/a2a-live.test.ts
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
import { A2AClient } from "../src/a2a-client.js";
|
|
11
|
+
|
|
12
|
+
const SKIP = !process.env.LIVE;
|
|
13
|
+
const STRATEGY_AGENT_URL = process.env.STRATEGY_AGENT_URL ?? "http://43.128.100.43:5085";
|
|
14
|
+
const ASSISTANT_ID = process.env.STRATEGY_ASSISTANT_ID ?? "d2310a07-b552-453c-a8bb-7b9b86de6b23";
|
|
15
|
+
|
|
16
|
+
describe.skipIf(SKIP)("L2 — Findoo A2A Live", { timeout: 180_000 }, () => {
|
|
17
|
+
const client = new A2AClient(STRATEGY_AGENT_URL, ASSISTANT_ID);
|
|
18
|
+
|
|
19
|
+
it("1. strategy-agent /ok endpoint is reachable", async () => {
|
|
20
|
+
const resp = await fetch(`${STRATEGY_AGENT_URL}/ok`, {
|
|
21
|
+
signal: AbortSignal.timeout(10_000),
|
|
22
|
+
});
|
|
23
|
+
expect(resp.ok).toBe(true);
|
|
24
|
+
const body = await resp.json();
|
|
25
|
+
expect(body).toHaveProperty("ok", true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("2. /assistants/search returns assistants", async () => {
|
|
29
|
+
const resp = await fetch(`${STRATEGY_AGENT_URL}/assistants/search`, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: { "Content-Type": "application/json" },
|
|
32
|
+
body: JSON.stringify({}),
|
|
33
|
+
signal: AbortSignal.timeout(10_000),
|
|
34
|
+
});
|
|
35
|
+
expect(resp.ok).toBe(true);
|
|
36
|
+
const assistants = await resp.json();
|
|
37
|
+
expect(Array.isArray(assistants)).toBe(true);
|
|
38
|
+
expect(assistants.length).toBeGreaterThan(0);
|
|
39
|
+
expect(assistants[0]).toHaveProperty("assistant_id");
|
|
40
|
+
expect(assistants[0]).toHaveProperty("graph_id", "strategy");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("3. A2A message/send returns valid JSON-RPC response", async () => {
|
|
44
|
+
const resp = await client.sendMessage("你好,请简要介绍你的能力", {
|
|
45
|
+
timeoutMs: 120_000,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(resp.jsonrpc).toBe("2.0");
|
|
49
|
+
expect(resp.id).toBeDefined();
|
|
50
|
+
// Should have result (success) or error
|
|
51
|
+
expect(resp.result !== undefined || resp.error !== undefined).toBe(true);
|
|
52
|
+
|
|
53
|
+
if (resp.result) {
|
|
54
|
+
console.log("[A2A] Result keys:", Object.keys(resp.result));
|
|
55
|
+
}
|
|
56
|
+
if (resp.error) {
|
|
57
|
+
console.log("[A2A] Error:", resp.error);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("4. A2A message/send with data part (structured context)", async () => {
|
|
62
|
+
const resp = await client.sendMessage("查看这只股票的基本情况", {
|
|
63
|
+
data: { symbol: "600519.SS", market: "cn" },
|
|
64
|
+
timeoutMs: 120_000,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(resp.jsonrpc).toBe("2.0");
|
|
68
|
+
if (resp.result) {
|
|
69
|
+
console.log("[A2A with data] Result preview:", JSON.stringify(resp.result).slice(0, 300));
|
|
70
|
+
}
|
|
71
|
+
if (resp.error) {
|
|
72
|
+
console.log("[A2A with data] Error:", resp.error);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("5. A2A message/stream returns SSE events and final result", async () => {
|
|
77
|
+
const events: Array<{ kind: string; state?: string; final: boolean }> = [];
|
|
78
|
+
|
|
79
|
+
for await (const event of client.sendMessageStream("你好,请简要介绍你的能力", {
|
|
80
|
+
timeoutMs: 120_000,
|
|
81
|
+
})) {
|
|
82
|
+
events.push({
|
|
83
|
+
kind: event.kind,
|
|
84
|
+
state: event.status?.state,
|
|
85
|
+
final: event.final,
|
|
86
|
+
});
|
|
87
|
+
console.log("[SSE]", event.kind, event.status?.state, event.final ? "(final)" : "");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Should have at least one event
|
|
91
|
+
expect(events.length).toBeGreaterThan(0);
|
|
92
|
+
// Last event should be final
|
|
93
|
+
const last = events[events.length - 1];
|
|
94
|
+
expect(last.final).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("6. collectStreamResult returns A2AResponse from stream", async () => {
|
|
98
|
+
const resp = await client.collectStreamResult("BTC当前价格趋势简要分析", {
|
|
99
|
+
timeoutMs: 120_000,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(resp.jsonrpc).toBe("2.0");
|
|
103
|
+
expect(resp.result !== undefined || resp.error !== undefined).toBe(true);
|
|
104
|
+
|
|
105
|
+
if (resp.result) {
|
|
106
|
+
console.log("[collectStream] Result preview:", JSON.stringify(resp.result).slice(0, 300));
|
|
107
|
+
}
|
|
108
|
+
if (resp.error) {
|
|
109
|
+
console.log("[collectStream] Error:", resp.error);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("7. A2A stream → grab taskId fast → background stream completes", async () => {
|
|
114
|
+
// The real async pattern: open stream, grab taskId from first event (~1-2s),
|
|
115
|
+
// then let the stream run in background until final event.
|
|
116
|
+
// Note: tasks/get doesn't work after stream ends (LangGraph cleans up),
|
|
117
|
+
// so the stream itself is the only reliable completion channel.
|
|
118
|
+
const start = Date.now();
|
|
119
|
+
let taskId: string | undefined;
|
|
120
|
+
|
|
121
|
+
const stream = client.sendMessageStream("简要分析A股大盘趋势", {
|
|
122
|
+
timeoutMs: 300_000,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Step 1: Read first event → get taskId (must be fast)
|
|
126
|
+
const first = await stream.next();
|
|
127
|
+
expect(first.done).toBe(false);
|
|
128
|
+
const firstRaw = first.value.raw as Record<string, unknown>;
|
|
129
|
+
taskId = (firstRaw.id ?? firstRaw.taskId) as string | undefined;
|
|
130
|
+
|
|
131
|
+
const submitMs = Date.now() - start;
|
|
132
|
+
console.log(`[Async] Got taskId in ${submitMs}ms:`, taskId);
|
|
133
|
+
expect(taskId).toBeDefined();
|
|
134
|
+
expect(submitMs).toBeLessThan(10_000); // taskId must arrive within 10s
|
|
135
|
+
|
|
136
|
+
// Step 2: Continue consuming stream until final event
|
|
137
|
+
let lastMessage: Record<string, unknown> | undefined;
|
|
138
|
+
let finalState: string | undefined;
|
|
139
|
+
|
|
140
|
+
for await (const event of stream) {
|
|
141
|
+
const msg = event.status?.message;
|
|
142
|
+
if (msg && typeof msg === "object") {
|
|
143
|
+
lastMessage = msg as Record<string, unknown>;
|
|
144
|
+
}
|
|
145
|
+
if (event.final) {
|
|
146
|
+
finalState = event.status?.state;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const totalMs = Date.now() - start;
|
|
152
|
+
console.log(`[Async] Stream completed in ${totalMs}ms, state=${finalState}`);
|
|
153
|
+
|
|
154
|
+
expect(finalState).toBe("completed");
|
|
155
|
+
|
|
156
|
+
// Verify we got actual content from the stream
|
|
157
|
+
if (lastMessage) {
|
|
158
|
+
const parts = lastMessage.parts as Array<Record<string, unknown>> | undefined;
|
|
159
|
+
if (Array.isArray(parts)) {
|
|
160
|
+
const text = parts
|
|
161
|
+
.filter((p: Record<string, unknown>) => typeof p.text === "string")
|
|
162
|
+
.map((p: Record<string, unknown>) => String(p.text))
|
|
163
|
+
.join("");
|
|
164
|
+
console.log(`[Async] Got ${text.length} chars of content`);
|
|
165
|
+
expect(text.length).toBeGreaterThan(0);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("8. A2A supports threaded conversation", async () => {
|
|
171
|
+
// First message — establish context
|
|
172
|
+
const resp1 = await client.sendMessage("记住这个:我关注茅台", {
|
|
173
|
+
timeoutMs: 120_000,
|
|
174
|
+
});
|
|
175
|
+
expect(resp1.jsonrpc).toBe("2.0");
|
|
176
|
+
|
|
177
|
+
// Extract threadId if available in result
|
|
178
|
+
const result1 = resp1.result as Record<string, unknown> | undefined;
|
|
179
|
+
const threadId = (result1?.thread_id ?? result1?.threadId ?? result1?.taskId) as
|
|
180
|
+
| string
|
|
181
|
+
| undefined;
|
|
182
|
+
|
|
183
|
+
if (threadId) {
|
|
184
|
+
// Second message — should have context
|
|
185
|
+
const resp2 = await client.sendMessage("我刚才说关注什么?", {
|
|
186
|
+
threadId,
|
|
187
|
+
timeoutMs: 120_000,
|
|
188
|
+
});
|
|
189
|
+
expect(resp2.jsonrpc).toBe("2.0");
|
|
190
|
+
console.log(
|
|
191
|
+
"[Thread] Follow-up result:",
|
|
192
|
+
JSON.stringify(resp2.result ?? resp2.error).slice(0, 300),
|
|
193
|
+
);
|
|
194
|
+
} else {
|
|
195
|
+
console.log("[Thread] No threadId in first response, skipping follow-up");
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L1 — PendingTaskTracker unit tests
|
|
3
|
+
*
|
|
4
|
+
* Tests stream-based tracking: trackStream consumes background SSE stream,
|
|
5
|
+
* fires onCompleted/onFailed callbacks on completion/error/timeout.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
|
+
import type { A2AStreamEvent } from "../src/a2a-client.js";
|
|
9
|
+
import { extractSummary, PendingTaskTracker } from "../src/pending-task-tracker.js";
|
|
10
|
+
|
|
11
|
+
/** Helper: create an async generator from an array of events */
|
|
12
|
+
async function* mockStream(events: A2AStreamEvent[]): AsyncGenerator<A2AStreamEvent> {
|
|
13
|
+
for (const e of events) {
|
|
14
|
+
yield e;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Helper: create a stream that yields events with delays */
|
|
19
|
+
async function* delayedStream(
|
|
20
|
+
events: Array<{ event: A2AStreamEvent; delayMs: number }>,
|
|
21
|
+
): AsyncGenerator<A2AStreamEvent> {
|
|
22
|
+
for (const { event, delayMs } of events) {
|
|
23
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
24
|
+
yield event;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function createMockA2A() {
|
|
29
|
+
return {} as unknown as import("../src/a2a-client.js").A2AClient;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeEvent(
|
|
33
|
+
kind: string,
|
|
34
|
+
state: string,
|
|
35
|
+
final: boolean,
|
|
36
|
+
extra?: Record<string, unknown>,
|
|
37
|
+
): A2AStreamEvent {
|
|
38
|
+
return {
|
|
39
|
+
kind: kind as A2AStreamEvent["kind"],
|
|
40
|
+
status: { state },
|
|
41
|
+
final,
|
|
42
|
+
raw: { kind, status: { state }, final, ...extra },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("PendingTaskTracker", () => {
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
vi.useFakeTimers();
|
|
49
|
+
});
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
vi.useRealTimers();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("submit() adds a task to pending list", () => {
|
|
55
|
+
const tracker = new PendingTaskTracker({
|
|
56
|
+
a2aClient: createMockA2A(),
|
|
57
|
+
onTaskCompleted: vi.fn(),
|
|
58
|
+
onTaskFailed: vi.fn(),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const task = tracker.submit("task-1", "分析茅台", {});
|
|
62
|
+
expect(task.taskId).toBe("task-1");
|
|
63
|
+
expect(task.query).toBe("分析茅台");
|
|
64
|
+
expect(task.status).toBe("submitted");
|
|
65
|
+
expect(tracker.getPending()).toHaveLength(1);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("trackStream() fires onCompleted when stream has final event", async () => {
|
|
69
|
+
vi.useRealTimers();
|
|
70
|
+
const onCompleted = vi.fn();
|
|
71
|
+
const tracker = new PendingTaskTracker({
|
|
72
|
+
a2aClient: createMockA2A(),
|
|
73
|
+
onTaskCompleted: onCompleted,
|
|
74
|
+
onTaskFailed: vi.fn(),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const stream = mockStream([
|
|
78
|
+
makeEvent("status-update", "working", false),
|
|
79
|
+
makeEvent("status-update", "working", false),
|
|
80
|
+
makeEvent("status-update", "completed", true, { result: "done" }),
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
tracker.trackStream("task-1", "分析茅台", stream);
|
|
84
|
+
|
|
85
|
+
// Wait for stream to be consumed
|
|
86
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
87
|
+
|
|
88
|
+
expect(onCompleted).toHaveBeenCalledOnce();
|
|
89
|
+
expect(onCompleted.mock.calls[0][0].taskId).toBe("task-1");
|
|
90
|
+
expect(onCompleted.mock.calls[0][0].status).toBe("completed");
|
|
91
|
+
expect(tracker.getPending()).toHaveLength(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("trackStream() fires onFailed on error event", async () => {
|
|
95
|
+
vi.useRealTimers();
|
|
96
|
+
const onFailed = vi.fn();
|
|
97
|
+
const tracker = new PendingTaskTracker({
|
|
98
|
+
a2aClient: createMockA2A(),
|
|
99
|
+
onTaskCompleted: vi.fn(),
|
|
100
|
+
onTaskFailed: onFailed,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const stream = mockStream([
|
|
104
|
+
{ kind: "error", final: true, raw: { error: "Agent crashed" } } as A2AStreamEvent,
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
tracker.trackStream("task-2", "BTC分析", stream);
|
|
108
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
109
|
+
|
|
110
|
+
expect(onFailed).toHaveBeenCalledOnce();
|
|
111
|
+
expect(onFailed.mock.calls[0][0].status).toBe("failed");
|
|
112
|
+
expect(tracker.getPending()).toHaveLength(0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("trackStream() updates task status to working", async () => {
|
|
116
|
+
vi.useRealTimers();
|
|
117
|
+
const onCompleted = vi.fn();
|
|
118
|
+
const tracker = new PendingTaskTracker({
|
|
119
|
+
a2aClient: createMockA2A(),
|
|
120
|
+
onTaskCompleted: onCompleted,
|
|
121
|
+
onTaskFailed: vi.fn(),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Use delayed stream so we can check intermediate state
|
|
125
|
+
const stream = delayedStream([
|
|
126
|
+
{ event: makeEvent("status-update", "working", false), delayMs: 10 },
|
|
127
|
+
{ event: makeEvent("status-update", "completed", true), delayMs: 50 },
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
tracker.trackStream("task-3", "宏观分析", stream);
|
|
131
|
+
|
|
132
|
+
// Check after first event
|
|
133
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
134
|
+
const pending = tracker.getPending();
|
|
135
|
+
if (pending.length > 0) {
|
|
136
|
+
expect(pending[0].status).toBe("working");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Wait for completion
|
|
140
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
141
|
+
expect(onCompleted).toHaveBeenCalledOnce();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("trackStream() handles stream ending without final flag", async () => {
|
|
145
|
+
vi.useRealTimers();
|
|
146
|
+
const onCompleted = vi.fn();
|
|
147
|
+
const tracker = new PendingTaskTracker({
|
|
148
|
+
a2aClient: createMockA2A(),
|
|
149
|
+
onTaskCompleted: onCompleted,
|
|
150
|
+
onTaskFailed: vi.fn(),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const stream = mockStream([
|
|
154
|
+
makeEvent("status-update", "working", false),
|
|
155
|
+
makeEvent("status-update", "working", false),
|
|
156
|
+
]);
|
|
157
|
+
|
|
158
|
+
tracker.trackStream("task-4", "ETF分析", stream);
|
|
159
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
160
|
+
|
|
161
|
+
// Should still complete (stream ended naturally)
|
|
162
|
+
expect(onCompleted).toHaveBeenCalledOnce();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("trackStream() fires onFailed on empty stream", async () => {
|
|
166
|
+
vi.useRealTimers();
|
|
167
|
+
const onFailed = vi.fn();
|
|
168
|
+
const tracker = new PendingTaskTracker({
|
|
169
|
+
a2aClient: createMockA2A(),
|
|
170
|
+
onTaskCompleted: vi.fn(),
|
|
171
|
+
onTaskFailed: onFailed,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const stream = mockStream([]);
|
|
175
|
+
tracker.trackStream("task-5", "空流", stream);
|
|
176
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
177
|
+
|
|
178
|
+
expect(onFailed).toHaveBeenCalledOnce();
|
|
179
|
+
expect(onFailed.mock.calls[0][1]).toContain("Stream ended without events");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("trackStream() fires onFailed on stream exception", async () => {
|
|
183
|
+
vi.useRealTimers();
|
|
184
|
+
const onFailed = vi.fn();
|
|
185
|
+
const tracker = new PendingTaskTracker({
|
|
186
|
+
a2aClient: createMockA2A(),
|
|
187
|
+
onTaskCompleted: vi.fn(),
|
|
188
|
+
onTaskFailed: onFailed,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
async function* errorStream(): AsyncGenerator<A2AStreamEvent> {
|
|
192
|
+
yield makeEvent("status-update", "working", false);
|
|
193
|
+
throw new Error("network failure");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
tracker.trackStream("task-6", "网络错误", errorStream());
|
|
197
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
198
|
+
|
|
199
|
+
expect(onFailed).toHaveBeenCalledOnce();
|
|
200
|
+
expect(onFailed.mock.calls[0][1]).toContain("network failure");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("trackStream() times out long-running streams", async () => {
|
|
204
|
+
vi.useRealTimers();
|
|
205
|
+
const onFailed = vi.fn();
|
|
206
|
+
const tracker = new PendingTaskTracker({
|
|
207
|
+
a2aClient: createMockA2A(),
|
|
208
|
+
onTaskCompleted: vi.fn(),
|
|
209
|
+
onTaskFailed: onFailed,
|
|
210
|
+
timeoutMs: 100, // very short for testing
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Stream that takes too long
|
|
214
|
+
async function* slowStream(): AsyncGenerator<A2AStreamEvent> {
|
|
215
|
+
yield makeEvent("status-update", "working", false);
|
|
216
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
217
|
+
yield makeEvent("status-update", "completed", true);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
tracker.trackStream("task-7", "超时测试", slowStream());
|
|
221
|
+
|
|
222
|
+
// Wait for timeout to trigger
|
|
223
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
224
|
+
|
|
225
|
+
expect(onFailed).toHaveBeenCalledOnce();
|
|
226
|
+
expect(onFailed.mock.calls[0][0].status).toBe("timeout");
|
|
227
|
+
expect(onFailed.mock.calls[0][1]).toContain("timed out");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("stop() clears all pending tasks", () => {
|
|
231
|
+
const tracker = new PendingTaskTracker({
|
|
232
|
+
a2aClient: createMockA2A(),
|
|
233
|
+
onTaskCompleted: vi.fn(),
|
|
234
|
+
onTaskFailed: vi.fn(),
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
tracker.submit("task-a", "测试A");
|
|
238
|
+
tracker.submit("task-b", "测试B");
|
|
239
|
+
expect(tracker.getPending()).toHaveLength(2);
|
|
240
|
+
|
|
241
|
+
tracker.stop();
|
|
242
|
+
expect(tracker.getPending()).toHaveLength(0);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("tracks contextId in tasks", () => {
|
|
246
|
+
const tracker = new PendingTaskTracker({
|
|
247
|
+
a2aClient: createMockA2A(),
|
|
248
|
+
onTaskCompleted: vi.fn(),
|
|
249
|
+
onTaskFailed: vi.fn(),
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const task = tracker.submit("task-ctx", "上下文测试", { contextId: "ctx-123" });
|
|
253
|
+
expect(task.contextId).toBe("ctx-123");
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe("extractSummary", () => {
|
|
258
|
+
it("extracts text from artifacts", () => {
|
|
259
|
+
const result = {
|
|
260
|
+
artifacts: [{ parts: [{ kind: "text", text: "茅台分析结果:估值偏高" }] }],
|
|
261
|
+
};
|
|
262
|
+
expect(extractSummary(result)).toBe("茅台分析结果:估值偏高");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("extracts text from status.message.parts", () => {
|
|
266
|
+
const result = {
|
|
267
|
+
status: {
|
|
268
|
+
state: "completed",
|
|
269
|
+
message: { parts: [{ kind: "text", text: "BTC 处于牛市周期" }] },
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
expect(extractSummary(result)).toBe("BTC 处于牛市周期");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("falls back to JSON.stringify", () => {
|
|
276
|
+
const result = { foo: "bar" };
|
|
277
|
+
expect(extractSummary(result)).toBe('{"foo":"bar"}');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("truncates long results", () => {
|
|
281
|
+
const longText = "x".repeat(3000);
|
|
282
|
+
const result = { artifacts: [{ parts: [{ kind: "text", text: longText }] }] };
|
|
283
|
+
const summary = extractSummary(result, 100);
|
|
284
|
+
expect(summary.length).toBe(101); // 100 + "…"
|
|
285
|
+
expect(summary.endsWith("…")).toBe(true);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import findooPlugin from "../index.js";
|
|
4
|
+
|
|
5
|
+
describe("findoo-alpha-plugin registration", () => {
|
|
6
|
+
function createMockApi(): OpenClawPluginApi & {
|
|
7
|
+
tools: Map<string, unknown>;
|
|
8
|
+
services: Map<string, unknown>;
|
|
9
|
+
logs: Array<{ level: string; msg: string }>;
|
|
10
|
+
} {
|
|
11
|
+
const tools = new Map<string, unknown>();
|
|
12
|
+
const services = new Map<string, unknown>();
|
|
13
|
+
const logs: Array<{ level: string; msg: string }> = [];
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
tools,
|
|
17
|
+
services,
|
|
18
|
+
logs,
|
|
19
|
+
pluginConfig: { apiKey: "test-license-key" },
|
|
20
|
+
resolvePath: (p: string) => `/tmp/test/${p}`,
|
|
21
|
+
logger: {
|
|
22
|
+
info: (msg: string) => logs.push({ level: "info", msg }),
|
|
23
|
+
warn: (msg: string) => logs.push({ level: "warn", msg }),
|
|
24
|
+
error: (msg: string) => logs.push({ level: "error", msg }),
|
|
25
|
+
debug: (msg: string) => logs.push({ level: "debug", msg }),
|
|
26
|
+
},
|
|
27
|
+
registerTool: (tool: { name: string }) => {
|
|
28
|
+
tools.set(tool.name, tool);
|
|
29
|
+
},
|
|
30
|
+
runtime: { services },
|
|
31
|
+
} as unknown as OpenClawPluginApi & {
|
|
32
|
+
tools: Map<string, unknown>;
|
|
33
|
+
services: Map<string, unknown>;
|
|
34
|
+
logs: Array<{ level: string; msg: string }>;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
it("has correct metadata", () => {
|
|
39
|
+
expect(findooPlugin.id).toBe("findoo-alpha-plugin");
|
|
40
|
+
expect(findooPlugin.name).toBe("Findoo Alpha");
|
|
41
|
+
expect(findooPlugin.kind).toBe("financial");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("registers 1 tool (async submit + heartbeat push mode)", () => {
|
|
45
|
+
// Mock fetch for startup health check
|
|
46
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
47
|
+
new Response(JSON.stringify({ ok: true }), { status: 200 }),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const api = createMockApi();
|
|
51
|
+
findooPlugin.register(api);
|
|
52
|
+
|
|
53
|
+
expect(api.tools.size).toBe(1);
|
|
54
|
+
expect(api.tools.has("fin_analyze")).toBe(true);
|
|
55
|
+
// fin_analyze_skills removed — no longer needed
|
|
56
|
+
expect(api.tools.has("fin_analyze_skills")).toBe(false);
|
|
57
|
+
|
|
58
|
+
vi.restoreAllMocks();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("registers fin-strategy-agent service", () => {
|
|
62
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
63
|
+
new Response(JSON.stringify({ ok: true }), { status: 200 }),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const api = createMockApi();
|
|
67
|
+
findooPlugin.register(api);
|
|
68
|
+
|
|
69
|
+
expect(api.services.has("fin-strategy-agent")).toBe(true);
|
|
70
|
+
const svc = api.services.get("fin-strategy-agent") as { getConfig: () => unknown };
|
|
71
|
+
const cfg = svc.getConfig() as { url: string; assistantId: string };
|
|
72
|
+
expect(cfg.url).toBe("http://43.128.100.43:5085");
|
|
73
|
+
expect(cfg.assistantId).toBe("d2310a07-b552-453c-a8bb-7b9b86de6b23");
|
|
74
|
+
|
|
75
|
+
vi.restoreAllMocks();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("skips registration without license key", () => {
|
|
79
|
+
const api = createMockApi();
|
|
80
|
+
(api as unknown as { pluginConfig: Record<string, unknown> }).pluginConfig = {};
|
|
81
|
+
findooPlugin.register(api);
|
|
82
|
+
|
|
83
|
+
expect(api.tools.size).toBe(0);
|
|
84
|
+
expect(api.services.size).toBe(0);
|
|
85
|
+
expect(api.logs.some((l) => l.msg.includes("license key not configured"))).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("logs startup info", () => {
|
|
89
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
90
|
+
new Response(JSON.stringify({ ok: true }), { status: 200 }),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const api = createMockApi();
|
|
94
|
+
findooPlugin.register(api);
|
|
95
|
+
|
|
96
|
+
expect(api.logs.some((l) => l.msg.includes("43.128.100.43:5085"))).toBe(true);
|
|
97
|
+
expect(api.logs.some((l) => l.msg.includes("d2310a07"))).toBe(true);
|
|
98
|
+
|
|
99
|
+
vi.restoreAllMocks();
|
|
100
|
+
});
|
|
101
|
+
});
|