Skip to content

Commit 11f15bc

Browse files
authored
Add Opik e2e coverage + toolCallId correlation (#11)
* Service: correlate tool spans by toolCallId/runId * Tests: cover toolCallId/runId correlation * Tests: add Opik service e2e * Scripts: add e2e test runner * CI: run Opik e2e tests when secrets available * Service: capture channelId/trigger context * Types: store channelId/trigger in active traces * Tests: cover channelId/trigger propagation * Tests: include channelId/trigger in e2e * CI: comment out Opik e2e step
1 parent 50ca767 commit 11f15bc

6 files changed

Lines changed: 493 additions & 18 deletions

File tree

.github/workflows/ci.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,15 @@ jobs:
3636
- name: Run smoke tests
3737
run: npm run smoke
3838

39+
# - name: Run E2E tests
40+
# if: ${{ secrets.OPIK_API_KEY != '' }}
41+
# env:
42+
# OPIK_E2E: "1"
43+
# OPIK_API_KEY: ${{ secrets.OPIK_API_KEY }}
44+
# OPIK_URL_OVERRIDE: ${{ secrets.OPIK_URL_OVERRIDE || 'https://www.comet.com/opik/api' }}
45+
# OPIK_PROJECT_NAME: ${{ secrets.OPIK_PROJECT_NAME || 'openclaw' }}
46+
# OPIK_WORKSPACE: ${{ secrets.OPIK_WORKSPACE || 'default' }}
47+
# run: npm run test:e2e
48+
3949
- name: Verify package payload
4050
run: npm pack --dry-run

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"lint": "tsc --noEmit",
1212
"typecheck": "tsc --noEmit",
1313
"test": "vitest run",
14+
"test:e2e": "vitest run src/**/*.e2e.test.ts",
1415
"smoke": "vitest run src/plugin.smoke.test.ts"
1516
},
1617
"dependencies": {

src/service.e2e.test.ts

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { randomUUID } from "node:crypto";
2+
import { describe, test } from "vitest";
3+
import { createOpikService } from "./service.js";
4+
5+
type HookHandler = (event: Record<string, unknown>, ctx: Record<string, unknown>) => void;
6+
7+
const requiredEnv = ["OPIK_API_KEY", "OPIK_URL_OVERRIDE"] as const;
8+
const missingEnv = requiredEnv.filter((key) => !process.env[key]);
9+
const e2eEnabled = process.env.OPIK_E2E === "1";
10+
11+
if (e2eEnabled && missingEnv.length > 0) {
12+
throw new Error(`Missing required env for Opik E2E: ${missingEnv.join(", ")}`);
13+
}
14+
15+
const describeMaybe = e2eEnabled ? describe : describe.skip;
16+
17+
describeMaybe("opik service e2e", () => {
18+
test(
19+
"exports a trace with tool + subagent events",
20+
{ timeout: 60_000 },
21+
async () => {
22+
const { api, hooks } = createApi();
23+
const service = createOpikService(api as any, { enabled: true });
24+
25+
await service.start(createServiceContext() as any);
26+
27+
const sessionKey = `e2e-${randomUUID()}`;
28+
const runId = `run-${randomUUID()}`;
29+
const toolCallId = `tool-${randomUUID()}`;
30+
const childSessionKey = `child-${randomUUID()}`;
31+
32+
invokeHook(
33+
hooks,
34+
"llm_input",
35+
{
36+
model: "gpt-4o-mini",
37+
provider: "openai",
38+
prompt: "Ping",
39+
systemPrompt: "You are an integration test.",
40+
imagesCount: 0,
41+
sessionId: `session-${randomUUID()}`,
42+
runId,
43+
historyMessages: [],
44+
},
45+
{
46+
sessionKey,
47+
agentId: "agent-e2e",
48+
messageProvider: "test",
49+
sessionId: `session-${randomUUID()}`,
50+
runId,
51+
channelId: "discord",
52+
trigger: "cron",
53+
},
54+
);
55+
56+
invokeHook(
57+
hooks,
58+
"before_tool_call",
59+
{
60+
toolName: "web_search",
61+
params: { query: "opik e2e" },
62+
toolCallId,
63+
runId,
64+
},
65+
{
66+
sessionKey,
67+
agentId: "agent-e2e",
68+
toolCallId,
69+
runId,
70+
sessionId: `session-${randomUUID()}`,
71+
},
72+
);
73+
74+
invokeHook(
75+
hooks,
76+
"after_tool_call",
77+
{
78+
toolName: "web_search",
79+
result: { ok: true },
80+
toolCallId,
81+
runId,
82+
durationMs: 12,
83+
},
84+
{
85+
sessionKey,
86+
agentId: "agent-e2e",
87+
toolCallId,
88+
runId,
89+
sessionId: `session-${randomUUID()}`,
90+
},
91+
);
92+
93+
invokeHook(
94+
hooks,
95+
"subagent_spawning",
96+
{
97+
childSessionKey,
98+
agentId: "agent-sub",
99+
label: "sub-e2e",
100+
mode: "assistant",
101+
requester: "integration",
102+
threadRequested: true,
103+
},
104+
{
105+
requesterSessionKey: sessionKey,
106+
childSessionKey,
107+
runId,
108+
},
109+
);
110+
111+
invokeHook(
112+
hooks,
113+
"subagent_spawned",
114+
{
115+
childSessionKey,
116+
agentId: "agent-sub",
117+
mode: "assistant",
118+
threadRequested: true,
119+
runId,
120+
},
121+
{
122+
requesterSessionKey: sessionKey,
123+
childSessionKey,
124+
runId,
125+
},
126+
);
127+
128+
invokeHook(
129+
hooks,
130+
"subagent_ended",
131+
{
132+
targetSessionKey: childSessionKey,
133+
targetKind: "assistant",
134+
outcome: "success",
135+
reason: "integration",
136+
endedAt: new Date().toISOString(),
137+
runId,
138+
},
139+
{
140+
requesterSessionKey: sessionKey,
141+
childSessionKey,
142+
runId,
143+
},
144+
);
145+
146+
invokeHook(
147+
hooks,
148+
"llm_output",
149+
{
150+
model: "gpt-4o-mini",
151+
provider: "openai",
152+
assistantTexts: ["Pong"],
153+
lastAssistant: "Pong",
154+
usage: { input: 1, output: 1, total: 2 },
155+
},
156+
{
157+
sessionKey,
158+
agentId: "agent-e2e",
159+
runId,
160+
channelId: "discord",
161+
trigger: "cron",
162+
},
163+
);
164+
165+
invokeHook(
166+
hooks,
167+
"agent_end",
168+
{
169+
success: true,
170+
durationMs: 25,
171+
},
172+
{
173+
sessionKey,
174+
agentId: "agent-e2e",
175+
runId,
176+
channelId: "discord",
177+
trigger: "cron",
178+
},
179+
);
180+
181+
await new Promise((resolve) => setTimeout(resolve, 0));
182+
await service.stop?.({} as any);
183+
},
184+
);
185+
});
186+
187+
function createApi() {
188+
const hooks: Record<string, HookHandler> = {};
189+
const api = {
190+
on: (hookName: string, handler: HookHandler) => {
191+
hooks[hookName] = handler;
192+
},
193+
registerService: () => undefined,
194+
};
195+
196+
return { api, hooks };
197+
}
198+
199+
function createServiceContext() {
200+
return {
201+
config: { opik: { enabled: true } },
202+
logger: {
203+
info: () => undefined,
204+
warn: () => undefined,
205+
},
206+
stateDir: "/tmp/opik-e2e",
207+
};
208+
}
209+
210+
function invokeHook(
211+
hooks: Record<string, HookHandler>,
212+
name: string,
213+
event: Record<string, unknown>,
214+
ctx: Record<string, unknown>,
215+
) {
216+
const hook = hooks[name];
217+
if (!hook) throw new Error(`Hook "${name}" not registered`);
218+
hook(event, ctx);
219+
}

0 commit comments

Comments
 (0)