ework-aio 0.2.9 → 0.2.10
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/package.json +1 -1
- package/scripts/e2e-browser.sh +39 -2
- package/scripts/fake-llm-server.ts +160 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ework-aio",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
4
4
|
"description": "All-in-one installer for ework (issue tracker) + ework-daemon (AI bridge) + opencode-ework (plugin). One command: npm i -g ework-aio && ework-aio install.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/scripts/e2e-browser.sh
CHANGED
|
@@ -67,6 +67,7 @@ info() { printf '%s…%s %s\n' "$c_ylw" "$c_rst" "$*" >&2; }
|
|
|
67
67
|
WORK_PORT="${WORK_PORT:-14002}"
|
|
68
68
|
DAEMON_PORT="${DAEMON_PORT:-14101}"
|
|
69
69
|
FAKE_LLM_PORT="${FAKE_LLM_PORT:-8400}"
|
|
70
|
+
BOT_LOGIN="${BOT_LOGIN:-e2e-bot}"
|
|
70
71
|
NPM_TAG="${NPM_TAG:-latest}"
|
|
71
72
|
DATA_DIR=/tmp/aio-browser
|
|
72
73
|
|
|
@@ -232,6 +233,13 @@ OPENCODE_DB="$HOME/.local/share/opencode/opencode.db"
|
|
|
232
233
|
sql_query() {
|
|
233
234
|
bun -e "const db=require('bun:sqlite');const d=new db.Database('$1',{readonly:true,create:false});try{process.stdout.write(JSON.stringify(d.prepare(\`$2\`).all()))}catch(e){process.stdout.write('')}" 2>/dev/null
|
|
234
235
|
}
|
|
236
|
+
# The smoke test in the previous step already wrote a session row. To avoid
|
|
237
|
+
# grabbing that one, capture its ID first and wait for a NEW session ID to
|
|
238
|
+
# appear (the daemon spawns its own opencode against a different --dir, so
|
|
239
|
+
# its session ID will differ).
|
|
240
|
+
PREV_SESSION_ID=$(sql_query "$OPENCODE_DB" "SELECT id FROM session ORDER BY time_updated DESC LIMIT 1;" \
|
|
241
|
+
| jq -r '.[0].id // empty' 2>/dev/null || echo "")
|
|
242
|
+
echo " smoke test session: ${PREV_SESSION_ID:-(none yet)}"
|
|
235
243
|
SESSION_ID=""
|
|
236
244
|
# 180s window: opencode spawn + fake-LLM roundtrip + write to DB. The
|
|
237
245
|
# daemon's poller picks up the issue within ~30s, then opencode takes
|
|
@@ -239,7 +247,7 @@ SESSION_ID=""
|
|
|
239
247
|
for i in $(seq 1 180); do
|
|
240
248
|
ROWS=$(sql_query "$OPENCODE_DB" "SELECT id FROM session ORDER BY time_updated DESC LIMIT 1;")
|
|
241
249
|
SESSION_ID=$(echo "$ROWS" | jq -r '.[0].id // empty' 2>/dev/null || echo "")
|
|
242
|
-
if [[ -n "$SESSION_ID" ]]; then
|
|
250
|
+
if [[ -n "$SESSION_ID" && "$SESSION_ID" != "$PREV_SESSION_ID" ]]; then
|
|
243
251
|
pass "session created: $SESSION_ID (after ${i}s)"
|
|
244
252
|
break
|
|
245
253
|
fi
|
|
@@ -249,7 +257,7 @@ for i in $(seq 1 180); do
|
|
|
249
257
|
tail -40 "$DATA_DIR/run/daemon.log" 2>/dev/null || echo "(no daemon.log)"
|
|
250
258
|
echo "--- fake-llm log (last 10) ---"
|
|
251
259
|
tail -10 /tmp/fake-llm.log
|
|
252
|
-
fail "opencode did not write a session";
|
|
260
|
+
fail "daemon's opencode did not write a new session (last seen: $SESSION_ID)";
|
|
253
261
|
}
|
|
254
262
|
done
|
|
255
263
|
|
|
@@ -292,6 +300,35 @@ echo "$SESSION_PAGE_HTML" | grep -q "E2E fake-LLM" \
|
|
|
292
300
|
|| fail "session page does not contain fake-LLM reply text"
|
|
293
301
|
pass "session page renders fake-LLM reply content"
|
|
294
302
|
|
|
303
|
+
info "check whether the bot actually replied on the issue (auto-reply)"
|
|
304
|
+
# ework-web exposes the issue comments via /api/v1/repos/<o>/<r>/issues/<n>/comments
|
|
305
|
+
# (Gitea-compatible). Two distinct kinds of bot-authored comments exist:
|
|
306
|
+
# 1. [system] "picked up this issue" — posted by the daemon on session start
|
|
307
|
+
# (NOT an LLM reply; doesn't count).
|
|
308
|
+
# 2. [bot] actual reply — posted by the LLM via the opencode-ework `reply`
|
|
309
|
+
# tool. This is the auto-reply behaviour we're validating.
|
|
310
|
+
COMMENTS_JSON=$(curl -sS -H "Cookie: $AUTH_COOKIE" \
|
|
311
|
+
"http://127.0.0.1:$WORK_PORT/api/v1/repos/e2e/browser-test/issues/1/comments")
|
|
312
|
+
COMMENTS_COUNT=$(echo "$COMMENTS_JSON" | jq 'length' 2>/dev/null || echo 0)
|
|
313
|
+
BOT_COMMENTS_COUNT=$(echo "$COMMENTS_JSON" \
|
|
314
|
+
| jq --arg bot "$BOT_LOGIN" '[.[] | select(.user.login == $bot)] | length' 2>/dev/null || echo 0)
|
|
315
|
+
LLM_REPLIES=$(echo "$COMMENTS_JSON" \
|
|
316
|
+
| jq --arg bot "$BOT_LOGIN" \
|
|
317
|
+
'[.[] | select(.user.login == $bot and (.body | startswith("[bot]")))] | length' 2>/dev/null || echo 0)
|
|
318
|
+
echo " comments on issue #1: total=$COMMENTS_COUNT bot=$BOT_COMMENTS_COUNT llm-reply=$LLM_REPLIES"
|
|
319
|
+
echo " --- all bot comment bodies (first 400 chars each) ---"
|
|
320
|
+
echo "$COMMENTS_JSON" | jq -r --arg bot "$BOT_LOGIN" \
|
|
321
|
+
'.[] | select(.user.login == $bot) | " • " + (.body | .[0:400])' 2>/dev/null
|
|
322
|
+
if [[ "$LLM_REPLIES" -ge 1 ]]; then
|
|
323
|
+
pass "bot auto-replied on issue #1 ($LLM_REPLIES [bot]-prefixed LLM reply)"
|
|
324
|
+
else
|
|
325
|
+
echo " --- daemon.log (last 40) ---"
|
|
326
|
+
tail -40 "$DATA_DIR/run/daemon.log" 2>/dev/null || echo "(no daemon.log)"
|
|
327
|
+
echo " --- fake-llm.log (last 20) ---"
|
|
328
|
+
tail -20 /tmp/fake-llm.log 2>/dev/null
|
|
329
|
+
fail "bot did NOT post an LLM-driven reply on issue #1 (got $BOT_COMMENTS_COUNT bot comments but 0 starting with [bot])"
|
|
330
|
+
fi
|
|
331
|
+
|
|
295
332
|
info "drive headless browser through the UI"
|
|
296
333
|
# bun (not tsx) because the script imports `bun:sqlite` to peek at opencode.db.
|
|
297
334
|
cp /host-test/browser-flow.ts /tmp/test-runner/
|
|
@@ -76,6 +76,9 @@ async function handleChatCompletion(req: Request): Promise<Response> {
|
|
|
76
76
|
log(` body: stream=${body?.stream} model=${body?.model} msgs=${body?.messages?.length} tools=${body?.tools?.length ?? 0}`);
|
|
77
77
|
|
|
78
78
|
const messages = Array.isArray(body?.messages) ? body.messages : [];
|
|
79
|
+
const tools = Array.isArray(body?.tools) ? body.tools : [];
|
|
80
|
+
const lastMsg = messages[messages.length - 1];
|
|
81
|
+
const lastRole = lastMsg?.role;
|
|
79
82
|
const userMsgs = messages.filter((m: any) => m?.role === "user");
|
|
80
83
|
const lastUser = userMsgs[userMsgs.length - 1];
|
|
81
84
|
const userText = typeof lastUser?.content === "string"
|
|
@@ -84,6 +87,36 @@ async function handleChatCompletion(req: Request): Promise<Response> {
|
|
|
84
87
|
? lastUser.content.map((p: any) => p?.text ?? "").join(" ")
|
|
85
88
|
: "";
|
|
86
89
|
|
|
90
|
+
const hasReplyTool = tools.some((t: any) => t?.function?.name === "reply");
|
|
91
|
+
|
|
92
|
+
// Tool-call path: when `reply` is registered and the last message is the
|
|
93
|
+
// user's initial prompt (not a tool_result), emit a tool_use so opencode
|
|
94
|
+
// actually invokes the reply tool → ework-web posts a [bot] comment on the
|
|
95
|
+
// issue. Without this the daemon only posts its [system] "picked up"
|
|
96
|
+
// notification and the LLM-driven auto-reply loop never fires.
|
|
97
|
+
//
|
|
98
|
+
// After opencode executes the tool it makes a follow-up call with
|
|
99
|
+
// role=tool in the messages — at that point we drop back to text.
|
|
100
|
+
if (hasReplyTool && lastRole === "user") {
|
|
101
|
+
const ref = parseIssueRef(userText);
|
|
102
|
+
if (ref) {
|
|
103
|
+
log(` emitting reply tool_use → ${ref.owner}/${ref.repo}#${ref.number}`);
|
|
104
|
+
const toolReplyBody =
|
|
105
|
+
`[bot] E2E fake-LLM auto-reply.\n\n` +
|
|
106
|
+
`Picked up ${ref.owner}/${ref.repo}#${ref.number}. ` +
|
|
107
|
+
`This is a stub reply emitted by scripts/fake-llm-server.ts to exercise ` +
|
|
108
|
+
`the opencode-ework \`reply\` tool end-to-end. The real value of this ` +
|
|
109
|
+
`test is that opencode received a tool_use, executed the reply tool, ` +
|
|
110
|
+
`and ework-web posted this comment as ${process.env.BOT_USERNAME ?? "bot"}.`;
|
|
111
|
+
return toolCallResponse(body?.model ?? "fake-model", "reply", {
|
|
112
|
+
owner: ref.owner,
|
|
113
|
+
repo: ref.repo,
|
|
114
|
+
number: ref.number,
|
|
115
|
+
body: toolReplyBody,
|
|
116
|
+
}, body?.stream === true);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
87
120
|
// Deterministic, context-aware reply: echo a snippet of the user's last
|
|
88
121
|
// message so the session transcript has traceable content (not just a
|
|
89
122
|
// fixed string). Truncated so the reply stays readable in the UI.
|
|
@@ -128,6 +161,133 @@ async function handleChatCompletion(req: Request): Promise<Response> {
|
|
|
128
161
|
});
|
|
129
162
|
}
|
|
130
163
|
|
|
164
|
+
// Parse an issue ref of the form `<owner>/<repo>#<n>` out of arbitrary text
|
|
165
|
+
// (typically the user prompt built by ework-daemon's buildInitialPrompt,
|
|
166
|
+
// which includes "(gitea:owner/repo#N)"). Returns null if no match.
|
|
167
|
+
function parseIssueRef(text: string): { owner: string; repo: string; number: number } | null {
|
|
168
|
+
const m = text.match(/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)#(\d+)/);
|
|
169
|
+
if (!m) return null;
|
|
170
|
+
const [, owner, repo, num] = m;
|
|
171
|
+
const number = parseInt(num, 10);
|
|
172
|
+
if (!Number.isFinite(number)) return null;
|
|
173
|
+
return { owner, repo, number };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Emit a tool_use response (OpenAI function-calling format). Supports both
|
|
177
|
+
// streaming and non-streaming because opencode defaults to stream=true but
|
|
178
|
+
// curl smoke tests use non-streaming.
|
|
179
|
+
function toolCallResponse(model: string, toolName: string, args: Record<string, unknown>, stream: boolean): Response {
|
|
180
|
+
const id = `chatcmpl-fake-${crypto.randomUUID()}`;
|
|
181
|
+
const created = Math.floor(Date.now() / 1000);
|
|
182
|
+
const callId = `call_${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`;
|
|
183
|
+
const argsJson = JSON.stringify(args);
|
|
184
|
+
const usage = {
|
|
185
|
+
prompt_tokens: roughTokens(argsJson),
|
|
186
|
+
completion_tokens: roughTokens(argsJson),
|
|
187
|
+
total_tokens: roughTokens(argsJson) * 2,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
if (!stream) {
|
|
191
|
+
return json({
|
|
192
|
+
id,
|
|
193
|
+
object: "chat.completion",
|
|
194
|
+
created,
|
|
195
|
+
model,
|
|
196
|
+
choices: [
|
|
197
|
+
{
|
|
198
|
+
index: 0,
|
|
199
|
+
message: {
|
|
200
|
+
role: "assistant",
|
|
201
|
+
content: null,
|
|
202
|
+
tool_calls: [
|
|
203
|
+
{
|
|
204
|
+
id: callId,
|
|
205
|
+
type: "function",
|
|
206
|
+
function: { name: toolName, arguments: argsJson },
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
},
|
|
210
|
+
finish_reason: "tool_calls",
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
usage,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const encoder = new TextEncoder();
|
|
218
|
+
const readable = new ReadableStream({
|
|
219
|
+
start(controller) {
|
|
220
|
+
// Initial chunk: declare the tool_call with name + empty arguments.
|
|
221
|
+
controller.enqueue(
|
|
222
|
+
encoder.encode(
|
|
223
|
+
sseLine({
|
|
224
|
+
id,
|
|
225
|
+
object: "chat.completion.chunk",
|
|
226
|
+
created,
|
|
227
|
+
model,
|
|
228
|
+
choices: [{
|
|
229
|
+
index: 0,
|
|
230
|
+
delta: {
|
|
231
|
+
role: "assistant",
|
|
232
|
+
content: null,
|
|
233
|
+
tool_calls: [{
|
|
234
|
+
index: 0,
|
|
235
|
+
id: callId,
|
|
236
|
+
type: "function",
|
|
237
|
+
function: { name: toolName, arguments: "" },
|
|
238
|
+
}],
|
|
239
|
+
},
|
|
240
|
+
finish_reason: null,
|
|
241
|
+
}],
|
|
242
|
+
}),
|
|
243
|
+
),
|
|
244
|
+
);
|
|
245
|
+
// Arguments chunk: full JSON in one shot (splitting is optional).
|
|
246
|
+
controller.enqueue(
|
|
247
|
+
encoder.encode(
|
|
248
|
+
sseLine({
|
|
249
|
+
id,
|
|
250
|
+
object: "chat.completion.chunk",
|
|
251
|
+
created,
|
|
252
|
+
model,
|
|
253
|
+
choices: [{
|
|
254
|
+
index: 0,
|
|
255
|
+
delta: {
|
|
256
|
+
tool_calls: [{ index: 0, function: { arguments: argsJson } }],
|
|
257
|
+
},
|
|
258
|
+
finish_reason: null,
|
|
259
|
+
}],
|
|
260
|
+
}),
|
|
261
|
+
),
|
|
262
|
+
);
|
|
263
|
+
// Final chunk: finish_reason + usage.
|
|
264
|
+
controller.enqueue(
|
|
265
|
+
encoder.encode(
|
|
266
|
+
sseLine({
|
|
267
|
+
id,
|
|
268
|
+
object: "chat.completion.chunk",
|
|
269
|
+
created,
|
|
270
|
+
model,
|
|
271
|
+
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
|
|
272
|
+
usage,
|
|
273
|
+
}),
|
|
274
|
+
),
|
|
275
|
+
);
|
|
276
|
+
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
277
|
+
controller.close();
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
return new Response(readable, {
|
|
282
|
+
headers: {
|
|
283
|
+
"content-type": "text/event-stream",
|
|
284
|
+
"cache-control": "no-cache",
|
|
285
|
+
"connection": "keep-alive",
|
|
286
|
+
"access-control-allow-origin": "*",
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
131
291
|
function streamResponse(model: string, reply: string, usage: any): Response {
|
|
132
292
|
const id = `chatcmpl-fake-${crypto.randomUUID()}`;
|
|
133
293
|
const created = Math.floor(Date.now() / 1000);
|