agent-dealer 0.1.3 → 0.1.5
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/bundle/server/dist/adapters/agent-deck-deliver.test.js +52 -0
- package/bundle/server/dist/adapters/agent-deck.js +140 -0
- package/bundle/server/dist/queue/approve-deliver.js +62 -0
- package/bundle/server/dist/queue/dispatcher.js +12 -0
- package/bundle/server/dist/queue/plan-delegation.js +27 -0
- package/bundle/server/dist/queue/plan-delegation.test.js +61 -0
- package/bundle/server/dist/queue/result-qa.js +66 -0
- package/bundle/server/dist/queue/result-qa.test.js +146 -0
- package/bundle/server/dist/queue/send-gate.test.js +108 -0
- package/bundle/server/dist/repository/outbound-drafts.js +62 -0
- package/bundle/server/dist/repository/result-qa.js +37 -0
- package/bundle/server/dist/routes/index.js +25 -10
- package/bundle/server/dist/runners/claude-args.js +29 -0
- package/bundle/server/dist/runners/claude.js +16 -18
- package/bundle/server/dist/runners/claude.test.js +75 -0
- package/bundle/server/dist/runners/persist.js +32 -6
- package/bundle/server/dist/runners/prompts.js +112 -9
- package/bundle/server/dist/runners/prompts.test.js +109 -1
- package/bundle/server/dist/runners/qa.js +41 -0
- package/bundle/server/dist/runners/run-context.js +15 -0
- package/bundle/server/dist/runners/stream-json.js +24 -1
- package/bundle/server/dist/runners/stream-json.test.js +39 -1
- package/bundle/server/dist/usage-summary.js +10 -3
- package/bundle/server/dist/usage-summary.test.js +33 -0
- package/bundle/server/package.json +3 -2
- package/bundle/server/static-ui/assets/index-D1y36xc4.js +78 -0
- package/bundle/server/static-ui/assets/index-ukkofc4N.css +1 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/index.d.ts +117 -109
- package/bundle/shared/dist/index.js +7 -1
- package/bundle/shared/dist/outbound-draft.d.ts +192 -0
- package/bundle/shared/dist/outbound-draft.js +46 -0
- package/bundle/shared/dist/outbound-draft.test.d.ts +1 -0
- package/bundle/shared/dist/outbound-draft.test.js +34 -0
- package/bundle/shared/dist/plan-triage.d.ts +4 -4
- package/bundle/shared/dist/plan-triage.js +2 -2
- package/bundle/shared/dist/result-qa.d.ts +50 -0
- package/bundle/shared/dist/result-qa.js +27 -0
- package/bundle/shared/dist/result-qa.test.d.ts +1 -0
- package/bundle/shared/dist/result-qa.test.js +63 -0
- package/bundle/shared/package.json +1 -1
- package/package.json +3 -2
- package/bundle/server/static-ui/assets/index-CH7u5bXC.js +0 -78
- package/bundle/server/static-ui/assets/index-CKuYOeDN.css +0 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { assertCallServiceToolSuccess, toCallServiceToolPayload } from "./agent-deck.js";
|
|
4
|
+
test("toCallServiceToolPayload maps serviceName to serviceId verbatim args", () => {
|
|
5
|
+
const toolCall = {
|
|
6
|
+
serviceName: "34eb6c24-f151-4da2-8db8-d6996aa296be",
|
|
7
|
+
toolName: "chat_postMessage",
|
|
8
|
+
arguments: { channel: "C123", text: "Hello world" },
|
|
9
|
+
};
|
|
10
|
+
const payload = toCallServiceToolPayload(toolCall);
|
|
11
|
+
assert.equal(payload.serviceId, toolCall.serviceName);
|
|
12
|
+
assert.equal(payload.toolName, toolCall.toolName);
|
|
13
|
+
assert.deepEqual(payload.arguments, toolCall.arguments);
|
|
14
|
+
});
|
|
15
|
+
test("assertCallServiceToolSuccess throws on deck proxy failure JSON", () => {
|
|
16
|
+
assert.throws(() => assertCallServiceToolSuccess({
|
|
17
|
+
content: [{ type: "text", text: JSON.stringify({ success: false, error: "Service not found" }) }],
|
|
18
|
+
}), /Service not found/);
|
|
19
|
+
});
|
|
20
|
+
test("assertCallServiceToolSuccess throws on nested Slack channel_not_found", () => {
|
|
21
|
+
const receipt = {
|
|
22
|
+
content: [
|
|
23
|
+
{
|
|
24
|
+
type: "text",
|
|
25
|
+
text: JSON.stringify({
|
|
26
|
+
content: [{ type: "text", text: "channel_not_found\n\nThe channel could not be found." }],
|
|
27
|
+
isError: true,
|
|
28
|
+
}),
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
assert.throws(() => assertCallServiceToolSuccess(receipt), /channel_not_found/);
|
|
33
|
+
});
|
|
34
|
+
test("assertCallServiceToolSuccess throws on deck-wrapped Slack JSON (live MCP shape)", () => {
|
|
35
|
+
const receipt = {
|
|
36
|
+
content: [
|
|
37
|
+
{
|
|
38
|
+
type: "text",
|
|
39
|
+
text: JSON.stringify({
|
|
40
|
+
content: [
|
|
41
|
+
{
|
|
42
|
+
type: "text",
|
|
43
|
+
text: "channel_not_found\n\nThe channel could not be found. It may not exist, may be in a different workspace than your app is installed on, or your app may lack permission to access it.",
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
isError: true,
|
|
47
|
+
}),
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
assert.throws(() => assertCallServiceToolSuccess(receipt), /channel_not_found/);
|
|
52
|
+
});
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
4
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
3
5
|
import { getAgentDeckConfig } from "../repository/intake-settings.js";
|
|
4
6
|
export function getAgentDeckApiUrl() {
|
|
5
7
|
if (process.env.AGENT_DECK_API_URL) {
|
|
@@ -134,3 +136,141 @@ export function isAgentDeckMcpRegistered() {
|
|
|
134
136
|
return false;
|
|
135
137
|
}
|
|
136
138
|
}
|
|
139
|
+
/** Map PRD toolCall to deck MCP call_service_tool args (serviceName → serviceId). */
|
|
140
|
+
export function toCallServiceToolPayload(toolCall) {
|
|
141
|
+
return {
|
|
142
|
+
serviceId: toolCall.serviceName,
|
|
143
|
+
toolName: toolCall.toolName,
|
|
144
|
+
arguments: toolCall.arguments,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function extractPermalink(toolResult) {
|
|
148
|
+
if (!toolResult || typeof toolResult !== "object")
|
|
149
|
+
return undefined;
|
|
150
|
+
const content = toolResult.content;
|
|
151
|
+
if (!Array.isArray(content))
|
|
152
|
+
return undefined;
|
|
153
|
+
for (const block of content) {
|
|
154
|
+
if (block.type !== "text" || !block.text)
|
|
155
|
+
continue;
|
|
156
|
+
try {
|
|
157
|
+
const parsed = JSON.parse(block.text);
|
|
158
|
+
if (parsed.permalink)
|
|
159
|
+
return parsed.permalink;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
const m = block.text.match(/https?:\/\/\S+/);
|
|
163
|
+
if (m)
|
|
164
|
+
return m[0];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
/** Parse Agent Deck call_service_tool MCP result — throws on proxy- or Slack-reported failure. */
|
|
170
|
+
export function assertCallServiceToolSuccess(toolResult) {
|
|
171
|
+
const err = findCallServiceToolError(toolResult);
|
|
172
|
+
if (err)
|
|
173
|
+
throw new Error(err);
|
|
174
|
+
}
|
|
175
|
+
function findCallServiceToolError(toolResult, depth = 0) {
|
|
176
|
+
if (!toolResult || typeof toolResult !== "object" || depth > 4)
|
|
177
|
+
return undefined;
|
|
178
|
+
const tr = toolResult;
|
|
179
|
+
if (tr.isError) {
|
|
180
|
+
const text = formatToolResultText(tr) ?? "call_service_tool failed";
|
|
181
|
+
const nested = parseNestedCallServiceToolError(text, depth);
|
|
182
|
+
if (nested)
|
|
183
|
+
return nested;
|
|
184
|
+
return summarizeToolErrorText(text) ?? text.slice(0, 500);
|
|
185
|
+
}
|
|
186
|
+
if (tr.success === false) {
|
|
187
|
+
return tr.error ?? "call_service_tool failed";
|
|
188
|
+
}
|
|
189
|
+
const text = formatToolResultText(tr);
|
|
190
|
+
if (text) {
|
|
191
|
+
const nested = parseNestedCallServiceToolError(text, depth);
|
|
192
|
+
if (nested)
|
|
193
|
+
return nested;
|
|
194
|
+
const plain = summarizeToolErrorText(text);
|
|
195
|
+
if (plain)
|
|
196
|
+
return plain;
|
|
197
|
+
}
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
function parseNestedCallServiceToolError(text, depth) {
|
|
201
|
+
try {
|
|
202
|
+
const parsed = JSON.parse(text);
|
|
203
|
+
return findCallServiceToolError(parsed, depth + 1);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const SLACK_ERROR_RE = /channel_not_found|not_in_channel|missing_scope|invalid_auth|user_not_found|cannot_dm|is_archived/i;
|
|
210
|
+
function summarizeToolErrorText(text) {
|
|
211
|
+
const t = text.trim();
|
|
212
|
+
if (!t)
|
|
213
|
+
return undefined;
|
|
214
|
+
try {
|
|
215
|
+
const parsed = JSON.parse(t);
|
|
216
|
+
if (typeof parsed.error === "string" && parsed.error.trim())
|
|
217
|
+
return parsed.error.trim();
|
|
218
|
+
if (typeof parsed.message === "string" && parsed.message.trim())
|
|
219
|
+
return parsed.message.trim();
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
// not top-level JSON — fall through
|
|
223
|
+
}
|
|
224
|
+
if (SLACK_ERROR_RE.test(t)) {
|
|
225
|
+
const line = t.split("\n").find((l) => SLACK_ERROR_RE.test(l));
|
|
226
|
+
if (line?.trim())
|
|
227
|
+
return line.trim().slice(0, 500);
|
|
228
|
+
const match = t.match(SLACK_ERROR_RE);
|
|
229
|
+
if (match)
|
|
230
|
+
return match[0];
|
|
231
|
+
}
|
|
232
|
+
if (/"success"\s*:\s*false/i.test(t)) {
|
|
233
|
+
try {
|
|
234
|
+
const parsed = JSON.parse(t);
|
|
235
|
+
if (typeof parsed.error === "string" && parsed.error.trim())
|
|
236
|
+
return parsed.error.trim();
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// ignore
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
function formatToolResultText(tr) {
|
|
245
|
+
if (!Array.isArray(tr.content))
|
|
246
|
+
return undefined;
|
|
247
|
+
return tr.content
|
|
248
|
+
.filter((b) => b.type === "text" && b.text)
|
|
249
|
+
.map((b) => b.text)
|
|
250
|
+
.join("\n")
|
|
251
|
+
.trim() || undefined;
|
|
252
|
+
}
|
|
253
|
+
export async function deliverOutboundDraft(deckId, toolCall, opts) {
|
|
254
|
+
const payload = toCallServiceToolPayload(toolCall);
|
|
255
|
+
const workspaceRoot = opts?.workspaceRoot ?? process.cwd();
|
|
256
|
+
const callTool = opts?.callTool ??
|
|
257
|
+
(async (p) => {
|
|
258
|
+
const mcpBase = getAgentDeckMcpUrl().replace(/\/mcp\/?$/, "");
|
|
259
|
+
const transport = new StreamableHTTPClientTransport(new URL(`${mcpBase}/mcp`));
|
|
260
|
+
const client = new Client({ name: "agent-dealer-deliver", version: "0.0.1" });
|
|
261
|
+
await client.connect(transport);
|
|
262
|
+
try {
|
|
263
|
+
await client.callTool({
|
|
264
|
+
name: "bind_workspace",
|
|
265
|
+
arguments: { deckId, workspaceRoot },
|
|
266
|
+
});
|
|
267
|
+
return await client.callTool({ name: "call_service_tool", arguments: p });
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
await client.close();
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
const toolResult = await callTool(payload);
|
|
274
|
+
assertCallServiceToolSuccess(toolResult);
|
|
275
|
+
return { toolResult, permalink: extractPermalink(toolResult) };
|
|
276
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { deliverOutboundDraft } from "../adapters/agent-deck.js";
|
|
2
|
+
import { syncLinearForRun } from "../adapters/linear-sync.js";
|
|
3
|
+
import { workspaceForRun } from "../runners/prompts.js";
|
|
4
|
+
import { addArtifact, appendEvent, getRun, transitionRun, } from "../repository/runs.js";
|
|
5
|
+
import { deliverInFlight, getPendingOutboundDraft, markOutboundDraftSent, } from "../repository/outbound-drafts.js";
|
|
6
|
+
import { scheduleReflect } from "./dispatcher.js";
|
|
7
|
+
export async function approveRunWithDeliver(runId, deps) {
|
|
8
|
+
const run = getRun(runId);
|
|
9
|
+
if (!run)
|
|
10
|
+
return { ok: false, code: 404, error: "Not found" };
|
|
11
|
+
if (run.status !== "review") {
|
|
12
|
+
return { ok: false, code: 400, error: "Run must be in review" };
|
|
13
|
+
}
|
|
14
|
+
if (deliverInFlight.has(runId)) {
|
|
15
|
+
return { ok: false, code: 409, error: "Deliver already in progress" };
|
|
16
|
+
}
|
|
17
|
+
const pending = getPendingOutboundDraft(runId);
|
|
18
|
+
if (!pending) {
|
|
19
|
+
const updated = transitionRun(runId, "done");
|
|
20
|
+
syncLinearForRun(updated, "done").catch((e) => console.error("[linear-sync] done:", e));
|
|
21
|
+
scheduleReflect(updated, { trigger: "approve" });
|
|
22
|
+
return { ok: true, run: updated, delivered: false };
|
|
23
|
+
}
|
|
24
|
+
if (!run.deckId) {
|
|
25
|
+
return { ok: false, code: 502, error: "No deck bound — cannot deliver outbound draft" };
|
|
26
|
+
}
|
|
27
|
+
const deliver = deps?.deliver ?? deliverOutboundDraft;
|
|
28
|
+
deliverInFlight.add(runId);
|
|
29
|
+
try {
|
|
30
|
+
let toolResult;
|
|
31
|
+
let permalink;
|
|
32
|
+
try {
|
|
33
|
+
const result = await deliver(run.deckId, pending.content.draft.toolCall, {
|
|
34
|
+
workspaceRoot: workspaceForRun(run),
|
|
35
|
+
});
|
|
36
|
+
toolResult = result.toolResult;
|
|
37
|
+
permalink = result.permalink;
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
41
|
+
appendEvent(runId, "deliver_failed", { error: message });
|
|
42
|
+
return { ok: false, code: 502, error: message };
|
|
43
|
+
}
|
|
44
|
+
const sent = markOutboundDraftSent(pending.artifact.id);
|
|
45
|
+
if (!sent) {
|
|
46
|
+
return { ok: false, code: 409, error: "Draft already sent or rejected" };
|
|
47
|
+
}
|
|
48
|
+
addArtifact(runId, "send_receipt", {
|
|
49
|
+
draftArtifactId: pending.artifact.id,
|
|
50
|
+
sentAt: new Date().toISOString(),
|
|
51
|
+
toolResult: toolResult,
|
|
52
|
+
permalink,
|
|
53
|
+
}, "system");
|
|
54
|
+
const updated = transitionRun(runId, "done");
|
|
55
|
+
syncLinearForRun(updated, "done").catch((e) => console.error("[linear-sync] done:", e));
|
|
56
|
+
scheduleReflect(updated, { trigger: "approve" });
|
|
57
|
+
return { ok: true, run: updated, delivered: true };
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
deliverInFlight.delete(runId);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -6,6 +6,7 @@ import { persistRunOutput, seedDeliverableFromParent } from "../runners/persist.
|
|
|
6
6
|
import { checkAgentDeckHealth } from "../adapters/agent-deck.js";
|
|
7
7
|
import { pollLinearIssues } from "../adapters/external.js";
|
|
8
8
|
import { syncLinearForRun } from "../adapters/linear-sync.js";
|
|
9
|
+
import { pendingSendCount } from "../repository/outbound-drafts.js";
|
|
9
10
|
import { listAgentsWithHealth } from "../adapters/agent-health.js";
|
|
10
11
|
import { listAgents } from "../repository/agents.js";
|
|
11
12
|
import { runReflect } from "../runners/reflect.js";
|
|
@@ -84,9 +85,18 @@ export function getSnapshot() {
|
|
|
84
85
|
openQuestionCounts[run.id] = n;
|
|
85
86
|
}
|
|
86
87
|
const awaitingAnswerRuns = awaitingPlanReview.filter((r) => openQuestionCounts[r.id]);
|
|
88
|
+
const pendingSendCounts = {};
|
|
89
|
+
for (const run of resultReviewRuns) {
|
|
90
|
+
const n = pendingSendCount(run.id);
|
|
91
|
+
if (n > 0)
|
|
92
|
+
pendingSendCounts[run.id] = n;
|
|
93
|
+
}
|
|
87
94
|
const autoApprovedRunIds = [...waitingExecution, ...runningRuns]
|
|
88
95
|
.filter((r) => getLatestArtifact(r.id, "approved_plan")?.author === "system")
|
|
89
96
|
.map((r) => r.id);
|
|
97
|
+
const sentRunIds = recentDone
|
|
98
|
+
.filter((r) => getLatestArtifact(r.id, "send_receipt"))
|
|
99
|
+
.map((r) => r.id);
|
|
90
100
|
return {
|
|
91
101
|
planReviewCount: awaitingPlanReview.length,
|
|
92
102
|
resultReviewCount: resultReviewRuns.length,
|
|
@@ -100,6 +110,8 @@ export function getSnapshot() {
|
|
|
100
110
|
awaitingPlanReview,
|
|
101
111
|
awaitingAnswerRuns,
|
|
102
112
|
openQuestionCounts,
|
|
113
|
+
pendingSendCounts,
|
|
114
|
+
sentRunIds,
|
|
103
115
|
autoApprovedRunIds,
|
|
104
116
|
runs: all,
|
|
105
117
|
agentDeckOnline: false,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { addArtifact, getLatestArtifact } from "../repository/runs.js";
|
|
2
|
+
/**
|
|
3
|
+
* Human approved a plan whose triage questions are still open.
|
|
4
|
+
* Record the delegation so the executor sees the questions it must decide itself.
|
|
5
|
+
* Call BEFORE markPlanTriageConsumed — a consumed triage has nothing to delegate.
|
|
6
|
+
*/
|
|
7
|
+
export function recordPlanDelegation(runId) {
|
|
8
|
+
const triageArt = getLatestArtifact(runId, "plan_triage");
|
|
9
|
+
if (!triageArt?.contentJson)
|
|
10
|
+
return false;
|
|
11
|
+
let triage;
|
|
12
|
+
try {
|
|
13
|
+
triage = JSON.parse(triageArt.contentJson);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
if (triage.consumed || triage.questions.length === 0)
|
|
19
|
+
return false;
|
|
20
|
+
// >= not >: createdAt has millisecond precision, and answers for this triage
|
|
21
|
+
// round can tie with it. Answers can never legitimately predate their triage.
|
|
22
|
+
const answers = getLatestArtifact(runId, "plan_answers");
|
|
23
|
+
if (answers && answers.createdAt >= triageArt.createdAt)
|
|
24
|
+
return false;
|
|
25
|
+
addArtifact(runId, "plan_answers", { answers: [], outcome: "delegated", answeredAt: new Date().toISOString() }, "human");
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { test, before } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-delegate-"));
|
|
7
|
+
process.env.MAX_CONCURRENT_RUNS = "0";
|
|
8
|
+
const { migrate } = await import("../db/index.js");
|
|
9
|
+
const { BUILTIN_AGENT_CLAUDE_ID } = await import("@agent-dealer/shared");
|
|
10
|
+
const { updateAgent } = await import("../repository/agents.js");
|
|
11
|
+
const { addArtifact, createRun, getLatestArtifact, markPlanTriageConsumed } = await import("../repository/runs.js");
|
|
12
|
+
const { recordPlanDelegation } = await import("./plan-delegation.js");
|
|
13
|
+
const QUESTIONS = [{ id: "q1", question: "Which backend?", options: [{ label: "SQLite" }, { label: "Postgres" }] }];
|
|
14
|
+
before(() => {
|
|
15
|
+
migrate();
|
|
16
|
+
updateAgent(BUILTIN_AGENT_CLAUDE_ID, { workspaceRoot: process.env.AGENT_DEALER_HOME });
|
|
17
|
+
});
|
|
18
|
+
function seedRun(triage) {
|
|
19
|
+
const run = createRun({
|
|
20
|
+
title: "Delegate test",
|
|
21
|
+
taskCategory: "other",
|
|
22
|
+
status: "plan_pending",
|
|
23
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
24
|
+
});
|
|
25
|
+
addArtifact(run.id, "draft_plan", { markdown: "# Plan" }, "agent");
|
|
26
|
+
if (triage)
|
|
27
|
+
addArtifact(run.id, "plan_triage", triage, "agent");
|
|
28
|
+
return run;
|
|
29
|
+
}
|
|
30
|
+
test("open questions produce a delegated plan_answers record", () => {
|
|
31
|
+
const run = seedRun({ verdict: "needs_review", rationale: "r", questions: QUESTIONS, parseFallback: false, consumed: false });
|
|
32
|
+
assert.equal(recordPlanDelegation(run.id), true);
|
|
33
|
+
const art = getLatestArtifact(run.id, "plan_answers");
|
|
34
|
+
const content = JSON.parse(art.contentJson);
|
|
35
|
+
assert.equal(content.outcome, "delegated");
|
|
36
|
+
assert.deepEqual(content.answers, []);
|
|
37
|
+
assert.equal(art.author, "human");
|
|
38
|
+
});
|
|
39
|
+
test("no triage means nothing to delegate", () => {
|
|
40
|
+
const run = seedRun();
|
|
41
|
+
assert.equal(recordPlanDelegation(run.id), false);
|
|
42
|
+
assert.equal(getLatestArtifact(run.id, "plan_answers"), null);
|
|
43
|
+
});
|
|
44
|
+
test("triage without questions means nothing to delegate", () => {
|
|
45
|
+
const run = seedRun({ verdict: "trivial", rationale: "r", questions: [], parseFallback: false, consumed: false });
|
|
46
|
+
assert.equal(recordPlanDelegation(run.id), false);
|
|
47
|
+
});
|
|
48
|
+
test("consumed triage means nothing to delegate", () => {
|
|
49
|
+
const run = seedRun({ verdict: "needs_review", rationale: "r", questions: QUESTIONS, parseFallback: false, consumed: false });
|
|
50
|
+
markPlanTriageConsumed(run.id);
|
|
51
|
+
assert.equal(recordPlanDelegation(run.id), false);
|
|
52
|
+
});
|
|
53
|
+
test("already-answered questions are not delegated", () => {
|
|
54
|
+
const run = seedRun({ verdict: "needs_review", rationale: "r", questions: QUESTIONS, parseFallback: false, consumed: false });
|
|
55
|
+
addArtifact(run.id, "plan_answers", {
|
|
56
|
+
answers: [{ questionId: "q1", selectedLabel: "SQLite" }],
|
|
57
|
+
outcome: "approved",
|
|
58
|
+
answeredAt: new Date().toISOString(),
|
|
59
|
+
}, "human");
|
|
60
|
+
assert.equal(recordPlanDelegation(run.id), false);
|
|
61
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { addArtifact, getRun } from "../repository/runs.js";
|
|
3
|
+
import { hasPendingQaExchange, latestExecuteSessionId } from "../repository/result-qa.js";
|
|
4
|
+
import { runQa } from "../runners/qa.js";
|
|
5
|
+
const ASKABLE_STATUSES = new Set(["review", "done"]);
|
|
6
|
+
const QA_RUNTIMES = new Set(["claude_code", "cursor_local"]);
|
|
7
|
+
/** Ask the run's agent about its finished result. Read-only; never changes run status. */
|
|
8
|
+
export function askResultQuestion(runId, question, opts) {
|
|
9
|
+
// An empty question would persist a result_qa artifact that ResultQaContent then
|
|
10
|
+
// refuses to parse — invisible in the thread, and hasPendingQaExchange would miss
|
|
11
|
+
// it, defeating the one-pending-question lock. Reject before spending anything.
|
|
12
|
+
const trimmed = question.trim();
|
|
13
|
+
if (!trimmed)
|
|
14
|
+
return { ok: false, code: 400, error: "Question cannot be empty" };
|
|
15
|
+
const run = getRun(runId);
|
|
16
|
+
if (!run)
|
|
17
|
+
return { ok: false, code: 404, error: "Not found" };
|
|
18
|
+
if (!ASKABLE_STATUSES.has(run.status)) {
|
|
19
|
+
return { ok: false, code: 409, error: `Can only ask about a finished result — run is ${run.status}` };
|
|
20
|
+
}
|
|
21
|
+
const runtime = run.runtime ?? "claude_code";
|
|
22
|
+
if (!QA_RUNTIMES.has(runtime)) {
|
|
23
|
+
return { ok: false, code: 409, error: `Q&A is unsupported for runtime ${runtime}` };
|
|
24
|
+
}
|
|
25
|
+
// The pending check and the insert below must stay in one synchronous block: every
|
|
26
|
+
// call here is sync (better-sqlite3), so no other request can interleave between them.
|
|
27
|
+
// Do not introduce an `await` before addArtifact — that would open a double-ask window.
|
|
28
|
+
if (hasPendingQaExchange(runId)) {
|
|
29
|
+
return { ok: false, code: 409, error: "A question is already being answered" };
|
|
30
|
+
}
|
|
31
|
+
const resumeSessionId = latestExecuteSessionId(runId);
|
|
32
|
+
const exchange = {
|
|
33
|
+
exchangeId: randomUUID(),
|
|
34
|
+
question: trimmed,
|
|
35
|
+
status: "pending",
|
|
36
|
+
sessionResumed: Boolean(resumeSessionId),
|
|
37
|
+
askedAt: new Date().toISOString(),
|
|
38
|
+
};
|
|
39
|
+
addArtifact(runId, "result_qa", exchange, "human");
|
|
40
|
+
void answerExchange(run, exchange, resumeSessionId, opts?.runner ?? runQa);
|
|
41
|
+
return { ok: true, exchange };
|
|
42
|
+
}
|
|
43
|
+
async function answerExchange(run, exchange, resumeSessionId, runner) {
|
|
44
|
+
let result;
|
|
45
|
+
try {
|
|
46
|
+
result = await runner(run, exchange.question, resumeSessionId);
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
appendExchange(run.id, { ...exchange, status: "failed", error: String(e) });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
addArtifact(run.id, "usage", result.usage, "agent");
|
|
53
|
+
if (!result.ok) {
|
|
54
|
+
appendExchange(run.id, { ...exchange, status: "failed", error: result.error ?? "Q&A failed" });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
appendExchange(run.id, {
|
|
58
|
+
...exchange,
|
|
59
|
+
status: "answered",
|
|
60
|
+
answer: result.answer,
|
|
61
|
+
answeredAt: new Date().toISOString(),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
function appendExchange(runId, exchange) {
|
|
65
|
+
addArtifact(runId, "result_qa", exchange, "agent");
|
|
66
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { test, before } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-qa-"));
|
|
7
|
+
process.env.MAX_CONCURRENT_RUNS = "0";
|
|
8
|
+
const { migrate } = await import("../db/index.js");
|
|
9
|
+
const { BUILTIN_AGENT_CLAUDE_ID } = await import("@agent-dealer/shared");
|
|
10
|
+
const { updateAgent } = await import("../repository/agents.js");
|
|
11
|
+
const { addArtifact, createRun, getLatestArtifact, getRun, transitionRun, updateRunFields } = await import("../repository/runs.js");
|
|
12
|
+
const { listQaExchanges, hasPendingQaExchange } = await import("../repository/result-qa.js");
|
|
13
|
+
const { askResultQuestion } = await import("./result-qa.js");
|
|
14
|
+
const USAGE = { phase: "qa", runtime: "claude_code", totalCostUsd: 0.02 };
|
|
15
|
+
before(() => {
|
|
16
|
+
migrate();
|
|
17
|
+
updateAgent(BUILTIN_AGENT_CLAUDE_ID, { workspaceRoot: process.env.AGENT_DEALER_HOME });
|
|
18
|
+
});
|
|
19
|
+
function seedReviewRun(opts) {
|
|
20
|
+
const run = createRun({
|
|
21
|
+
title: "QA test",
|
|
22
|
+
taskCategory: "other",
|
|
23
|
+
status: "plan_approved",
|
|
24
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
25
|
+
});
|
|
26
|
+
if (opts?.session) {
|
|
27
|
+
addArtifact(run.id, "agent_session", { phase: "execute", runtime: "claude_code", sessionId: opts.session }, "agent");
|
|
28
|
+
}
|
|
29
|
+
transitionRun(run.id, "running");
|
|
30
|
+
transitionRun(run.id, "review");
|
|
31
|
+
return getRun(run.id);
|
|
32
|
+
}
|
|
33
|
+
/** Resolves the async answer job before asserting. */
|
|
34
|
+
async function settle() {
|
|
35
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
36
|
+
}
|
|
37
|
+
test("asking persists a pending exchange, then an answered one with the same exchangeId", async () => {
|
|
38
|
+
const run = seedReviewRun({ session: "sess-exec" });
|
|
39
|
+
let sawSession = "unset";
|
|
40
|
+
const res = askResultQuestion(run.id, "Why SQLite?", {
|
|
41
|
+
runner: async (_run, _q, resumeSessionId) => {
|
|
42
|
+
sawSession = resumeSessionId;
|
|
43
|
+
return { ok: true, answer: "Single-process.", usage: USAGE };
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
assert.equal(res.ok, true);
|
|
47
|
+
assert.equal(res.ok && res.exchange.status, "pending");
|
|
48
|
+
assert.equal(listQaExchanges(run.id)[0].status, "pending");
|
|
49
|
+
await settle();
|
|
50
|
+
assert.equal(sawSession, "sess-exec");
|
|
51
|
+
const thread = listQaExchanges(run.id);
|
|
52
|
+
assert.equal(thread.length, 1, "append-only exchanges collapse by exchangeId");
|
|
53
|
+
assert.equal(thread[0].status, "answered");
|
|
54
|
+
assert.equal(thread[0].answer, "Single-process.");
|
|
55
|
+
assert.equal(thread[0].sessionResumed, true);
|
|
56
|
+
assert.ok(thread[0].answeredAt);
|
|
57
|
+
assert.equal(getLatestArtifact(run.id, "usage").contentJson.includes('"phase":"qa"'), true);
|
|
58
|
+
});
|
|
59
|
+
test("missing execute session answers ungrounded and flags sessionResumed false", async () => {
|
|
60
|
+
const run = seedReviewRun();
|
|
61
|
+
askResultQuestion(run.id, "What did you check?", {
|
|
62
|
+
runner: async () => ({ ok: true, answer: "The tests.", usage: USAGE }),
|
|
63
|
+
});
|
|
64
|
+
await settle();
|
|
65
|
+
const thread = listQaExchanges(run.id);
|
|
66
|
+
assert.equal(thread[0].sessionResumed, false);
|
|
67
|
+
assert.equal(thread[0].status, "answered");
|
|
68
|
+
});
|
|
69
|
+
test("runner failure records a failed exchange and never changes run status", async () => {
|
|
70
|
+
const run = seedReviewRun({ session: "sess-exec" });
|
|
71
|
+
askResultQuestion(run.id, "Why?", {
|
|
72
|
+
runner: async () => ({ ok: false, answer: "", error: "qa exited 1", usage: USAGE }),
|
|
73
|
+
});
|
|
74
|
+
await settle();
|
|
75
|
+
const thread = listQaExchanges(run.id);
|
|
76
|
+
assert.equal(thread[0].status, "failed");
|
|
77
|
+
assert.equal(thread[0].error, "qa exited 1");
|
|
78
|
+
assert.equal(getRun(run.id).status, "review");
|
|
79
|
+
});
|
|
80
|
+
test("runner throwing records a failed exchange", async () => {
|
|
81
|
+
const run = seedReviewRun({ session: "sess-exec" });
|
|
82
|
+
askResultQuestion(run.id, "Why?", {
|
|
83
|
+
runner: async () => { throw new Error("spawn ENOENT"); },
|
|
84
|
+
});
|
|
85
|
+
await settle();
|
|
86
|
+
assert.match(listQaExchanges(run.id)[0].error, /spawn ENOENT/);
|
|
87
|
+
});
|
|
88
|
+
test("a second question while one is pending returns 409", () => {
|
|
89
|
+
const run = seedReviewRun({ session: "sess-exec" });
|
|
90
|
+
askResultQuestion(run.id, "First?", { runner: () => new Promise(() => { }) });
|
|
91
|
+
const second = askResultQuestion(run.id, "Second?", { runner: async () => ({ ok: true, answer: "x", usage: USAGE }) });
|
|
92
|
+
assert.equal(!second.ok && second.code, 409);
|
|
93
|
+
});
|
|
94
|
+
test("asking a done run is allowed", async () => {
|
|
95
|
+
const run = seedReviewRun({ session: "sess-exec" });
|
|
96
|
+
transitionRun(run.id, "done");
|
|
97
|
+
const res = askResultQuestion(run.id, "Post-hoc?", {
|
|
98
|
+
runner: async () => ({ ok: true, answer: "Sure.", usage: USAGE }),
|
|
99
|
+
});
|
|
100
|
+
assert.equal(res.ok, true);
|
|
101
|
+
await settle();
|
|
102
|
+
assert.equal(listQaExchanges(run.id)[0].status, "answered");
|
|
103
|
+
});
|
|
104
|
+
test("asking a running run returns 409", () => {
|
|
105
|
+
const run = createRun({
|
|
106
|
+
title: "Still running",
|
|
107
|
+
taskCategory: "other",
|
|
108
|
+
status: "plan_approved",
|
|
109
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
110
|
+
});
|
|
111
|
+
transitionRun(run.id, "running");
|
|
112
|
+
const res = askResultQuestion(run.id, "Why?", { runner: async () => ({ ok: true, answer: "x", usage: USAGE }) });
|
|
113
|
+
assert.equal(!res.ok && res.code, 409);
|
|
114
|
+
});
|
|
115
|
+
test("whitespace-only question is rejected before spending anything", async () => {
|
|
116
|
+
const run = seedReviewRun({ session: "sess-exec" });
|
|
117
|
+
let runnerCalled = false;
|
|
118
|
+
const res = askResultQuestion(run.id, " ", {
|
|
119
|
+
runner: async () => { runnerCalled = true; return { ok: true, answer: "x", usage: USAGE }; },
|
|
120
|
+
});
|
|
121
|
+
assert.equal(!res.ok && res.code, 400);
|
|
122
|
+
await settle();
|
|
123
|
+
assert.equal(runnerCalled, false, "no paid agent call");
|
|
124
|
+
assert.equal(listQaExchanges(run.id).length, 0, "no artifact persisted");
|
|
125
|
+
assert.equal(hasPendingQaExchange(run.id), false, "pending lock not corrupted");
|
|
126
|
+
});
|
|
127
|
+
test("a persisted exchange always round-trips through the content schema", async () => {
|
|
128
|
+
const run = seedReviewRun({ session: "sess-exec" });
|
|
129
|
+
askResultQuestion(run.id, " Why SQLite? ", {
|
|
130
|
+
runner: async () => ({ ok: true, answer: "Single-process.", usage: USAGE }),
|
|
131
|
+
});
|
|
132
|
+
await settle();
|
|
133
|
+
const thread = listQaExchanges(run.id);
|
|
134
|
+
assert.equal(thread.length, 1, "exchange is visible, i.e. it parsed");
|
|
135
|
+
assert.equal(thread[0].question, "Why SQLite?", "stored trimmed");
|
|
136
|
+
});
|
|
137
|
+
test("unknown run returns 404", () => {
|
|
138
|
+
const res = askResultQuestion("00000000-0000-4000-a000-0000000000ff", "Why?");
|
|
139
|
+
assert.equal(!res.ok && res.code, 404);
|
|
140
|
+
});
|
|
141
|
+
test("cursor runtime is allowed", () => {
|
|
142
|
+
const run = seedReviewRun({ session: "sess-exec" });
|
|
143
|
+
updateRunFields(run.id, { runtime: "cursor_local" });
|
|
144
|
+
const res = askResultQuestion(run.id, "Why?", { runner: async () => ({ ok: true, answer: "x", usage: USAGE }) });
|
|
145
|
+
assert.equal(res.ok, true);
|
|
146
|
+
});
|