agent-dealer 0.1.2 → 0.1.4
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/answers.test.js +117 -0
- package/bundle/server/dist/queue/approve-deliver.js +62 -0
- package/bundle/server/dist/queue/dispatcher.js +176 -2
- package/bundle/server/dist/queue/plan-gate.test.js +55 -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/runs.js +17 -1
- package/bundle/server/dist/routes/index.js +41 -17
- package/bundle/server/dist/runners/claude-args.js +24 -0
- package/bundle/server/dist/runners/claude.js +9 -18
- package/bundle/server/dist/runners/claude.test.js +58 -0
- package/bundle/server/dist/runners/persist.js +46 -7
- package/bundle/server/dist/runners/prompts.js +87 -1
- package/bundle/server/dist/runners/prompts.test.js +92 -0
- package/bundle/server/dist/runners/reflect.js +1 -1
- package/bundle/server/dist/runners/stream-json.js +51 -0
- package/bundle/server/dist/runners/stream-json.test.js +95 -0
- package/bundle/server/dist/usage-summary.js +9 -4
- package/bundle/server/package.json +3 -2
- package/bundle/server/static-ui/assets/index-Cc-h06U6.js +78 -0
- package/bundle/server/static-ui/assets/index-DKnME33I.css +1 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/budget.d.ts +1 -1
- package/bundle/shared/dist/budget.js +3 -3
- package/bundle/shared/dist/index.d.ts +351 -129
- package/bundle/shared/dist/index.js +19 -0
- 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 +317 -0
- package/bundle/shared/dist/plan-triage.js +65 -0
- package/bundle/shared/dist/plan-triage.test.d.ts +1 -0
- package/bundle/shared/dist/plan-triage.test.js +66 -0
- package/bundle/shared/package.json +1 -1
- package/package.json +3 -2
- package/bundle/server/static-ui/assets/index-ChdPKYDd.css +0 -1
- package/bundle/server/static-ui/assets/index-hIzZ8TM8.js +0 -77
|
@@ -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,117 @@
|
|
|
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-answers-"));
|
|
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, markPlanTriageConsumed, updateRunFields } = await import("../repository/runs.js");
|
|
12
|
+
const { submitPlanAnswers } = await import("./dispatcher.js");
|
|
13
|
+
const QUESTIONS = [
|
|
14
|
+
{ id: "q1", question: "Which approach?", options: [{ label: "A" }, { label: "B" }] },
|
|
15
|
+
{ id: "q2", question: "Ship docs too?", options: [{ label: "Yes" }, { label: "No" }] },
|
|
16
|
+
];
|
|
17
|
+
before(() => {
|
|
18
|
+
migrate();
|
|
19
|
+
updateAgent(BUILTIN_AGENT_CLAUDE_ID, { workspaceRoot: process.env.AGENT_DEALER_HOME });
|
|
20
|
+
});
|
|
21
|
+
function seedQuestionRun() {
|
|
22
|
+
const run = createRun({
|
|
23
|
+
title: "Answers test",
|
|
24
|
+
taskCategory: "other",
|
|
25
|
+
status: "plan_pending",
|
|
26
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
27
|
+
});
|
|
28
|
+
addArtifact(run.id, "draft_plan", { markdown: "# Plan", sessionId: "sess-1" }, "agent");
|
|
29
|
+
addArtifact(run.id, "plan_triage", { verdict: "needs_review", rationale: "r", questions: QUESTIONS, sessionId: "sess-1", parseFallback: false, consumed: false }, "agent");
|
|
30
|
+
return getRun(run.id);
|
|
31
|
+
}
|
|
32
|
+
test("all-structured answers approve and mark plan_answers approved", () => {
|
|
33
|
+
const run = seedQuestionRun();
|
|
34
|
+
const res = submitPlanAnswers(run.id, {
|
|
35
|
+
answers: [
|
|
36
|
+
{ questionId: "q1", selectedLabel: "A" },
|
|
37
|
+
{ questionId: "q2", selectedLabel: "No" },
|
|
38
|
+
],
|
|
39
|
+
});
|
|
40
|
+
assert.equal(res.ok, true);
|
|
41
|
+
assert.equal(res.ok && res.outcome, "approved");
|
|
42
|
+
assert.equal(getRun(run.id).status, "plan_approved");
|
|
43
|
+
const ans = getLatestArtifact(run.id, "plan_answers");
|
|
44
|
+
assert.match(ans.contentJson, /"outcome":"approved"/);
|
|
45
|
+
assert.equal(getLatestArtifact(run.id, "approved_plan").author, "human");
|
|
46
|
+
const triage = getLatestArtifact(run.id, "plan_triage");
|
|
47
|
+
assert.match(triage.contentJson, /"consumed":true/);
|
|
48
|
+
});
|
|
49
|
+
test("executeModel null does not wipe seeded execute_model", () => {
|
|
50
|
+
const run = seedQuestionRun();
|
|
51
|
+
updateRunFields(run.id, { execute_model: "claude-sonnet-4" });
|
|
52
|
+
const res = submitPlanAnswers(run.id, {
|
|
53
|
+
answers: [
|
|
54
|
+
{ questionId: "q1", selectedLabel: "A" },
|
|
55
|
+
{ questionId: "q2", selectedLabel: "No" },
|
|
56
|
+
],
|
|
57
|
+
executeModel: null,
|
|
58
|
+
});
|
|
59
|
+
assert.equal(res.ok, true);
|
|
60
|
+
assert.equal(getRun(run.id).executeModel, "claude-sonnet-4");
|
|
61
|
+
});
|
|
62
|
+
test("free-form answer schedules a redraft instead of approving", () => {
|
|
63
|
+
const run = seedQuestionRun();
|
|
64
|
+
let redrafted = false;
|
|
65
|
+
const res = submitPlanAnswers(run.id, {
|
|
66
|
+
answers: [
|
|
67
|
+
{ questionId: "q1", freeText: "Do it a third way" },
|
|
68
|
+
{ questionId: "q2", selectedLabel: "Yes" },
|
|
69
|
+
],
|
|
70
|
+
}, { onRedraft: () => { redrafted = true; } });
|
|
71
|
+
assert.equal(res.ok && res.outcome, "redraft");
|
|
72
|
+
assert.equal(redrafted, true);
|
|
73
|
+
assert.equal(getRun(run.id).status, "plan_pending");
|
|
74
|
+
});
|
|
75
|
+
test("answers must cover every open question exactly once", () => {
|
|
76
|
+
const run = seedQuestionRun();
|
|
77
|
+
const res = submitPlanAnswers(run.id, { answers: [{ questionId: "q1", selectedLabel: "A" }] });
|
|
78
|
+
assert.equal(res.ok, false);
|
|
79
|
+
assert.equal(!res.ok && res.code, 400);
|
|
80
|
+
});
|
|
81
|
+
test("double submit returns 409", () => {
|
|
82
|
+
const run = seedQuestionRun();
|
|
83
|
+
submitPlanAnswers(run.id, {
|
|
84
|
+
answers: [
|
|
85
|
+
{ questionId: "q1", selectedLabel: "A" },
|
|
86
|
+
{ questionId: "q2", selectedLabel: "Yes" },
|
|
87
|
+
],
|
|
88
|
+
});
|
|
89
|
+
const again = submitPlanAnswers(run.id, {
|
|
90
|
+
answers: [
|
|
91
|
+
{ questionId: "q1", selectedLabel: "B" },
|
|
92
|
+
{ questionId: "q2", selectedLabel: "No" },
|
|
93
|
+
],
|
|
94
|
+
});
|
|
95
|
+
assert.equal(!again.ok && again.code, 409);
|
|
96
|
+
});
|
|
97
|
+
test("run without open questions returns 409", () => {
|
|
98
|
+
const run = createRun({
|
|
99
|
+
title: "No questions",
|
|
100
|
+
taskCategory: "other",
|
|
101
|
+
status: "plan_pending",
|
|
102
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
103
|
+
});
|
|
104
|
+
const res = submitPlanAnswers(run.id, { answers: [{ questionId: "q1", selectedLabel: "A" }] });
|
|
105
|
+
assert.equal(!res.ok && res.code, 409);
|
|
106
|
+
});
|
|
107
|
+
test("consumed triage rejects answers", () => {
|
|
108
|
+
const run = seedQuestionRun();
|
|
109
|
+
markPlanTriageConsumed(run.id);
|
|
110
|
+
const res = submitPlanAnswers(run.id, {
|
|
111
|
+
answers: [
|
|
112
|
+
{ questionId: "q1", selectedLabel: "A" },
|
|
113
|
+
{ questionId: "q2", selectedLabel: "Yes" },
|
|
114
|
+
],
|
|
115
|
+
});
|
|
116
|
+
assert.equal(!res.ok && res.code, 409);
|
|
117
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { planGateDecision } from "@agent-dealer/shared";
|
|
2
|
+
import { buildPlanRevisePrompt } from "../runners/prompts.js";
|
|
3
|
+
import { addArtifact, countByStatus, getLatestArtifact, getRun, listArtifacts, listRuns, markPlanTriageConsumed, patchRunPhaseBudget, transitionRun, updateRunFields, } from "../repository/runs.js";
|
|
2
4
|
import { runAgent } from "../runners/claude.js";
|
|
3
5
|
import { persistRunOutput, seedDeliverableFromParent } from "../runners/persist.js";
|
|
4
6
|
import { checkAgentDeckHealth } from "../adapters/agent-deck.js";
|
|
5
7
|
import { pollLinearIssues } from "../adapters/external.js";
|
|
6
8
|
import { syncLinearForRun } from "../adapters/linear-sync.js";
|
|
9
|
+
import { pendingSendCount } from "../repository/outbound-drafts.js";
|
|
7
10
|
import { listAgentsWithHealth } from "../adapters/agent-health.js";
|
|
8
11
|
import { listAgents } from "../repository/agents.js";
|
|
9
12
|
import { runReflect } from "../runners/reflect.js";
|
|
@@ -42,6 +45,24 @@ function isReadyForPlanReview(run) {
|
|
|
42
45
|
!!getLatestArtifact(run.id, "draft_plan") &&
|
|
43
46
|
!activePlanDrafts.has(run.id));
|
|
44
47
|
}
|
|
48
|
+
/** Open (unanswered, unconsumed) question count for a plan_pending run. */
|
|
49
|
+
function openQuestionCount(runId) {
|
|
50
|
+
const tri = getLatestArtifact(runId, "plan_triage");
|
|
51
|
+
if (!tri?.contentJson)
|
|
52
|
+
return 0;
|
|
53
|
+
try {
|
|
54
|
+
const c = JSON.parse(tri.contentJson);
|
|
55
|
+
if (c.consumed || !c.questions?.length)
|
|
56
|
+
return 0;
|
|
57
|
+
const ans = getLatestArtifact(runId, "plan_answers");
|
|
58
|
+
if (ans && ans.createdAt > tri.createdAt)
|
|
59
|
+
return 0;
|
|
60
|
+
return c.questions.length;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
45
66
|
export function getSnapshot() {
|
|
46
67
|
const all = listRuns();
|
|
47
68
|
const runningRuns = all.filter((r) => r.status === "running");
|
|
@@ -57,6 +78,25 @@ export function getSnapshot() {
|
|
|
57
78
|
const awaitingPlanReview = planningPhase.filter(isReadyForPlanReview);
|
|
58
79
|
const planningActiveRuns = planningPhase.filter((r) => activePlanDrafts.has(r.id));
|
|
59
80
|
const planningQueuedRuns = planningPhase.filter((r) => !activePlanDrafts.has(r.id) && !isReadyForPlanReview(r));
|
|
81
|
+
const openQuestionCounts = {};
|
|
82
|
+
for (const run of awaitingPlanReview) {
|
|
83
|
+
const n = openQuestionCount(run.id);
|
|
84
|
+
if (n > 0)
|
|
85
|
+
openQuestionCounts[run.id] = n;
|
|
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
|
+
}
|
|
94
|
+
const autoApprovedRunIds = [...waitingExecution, ...runningRuns]
|
|
95
|
+
.filter((r) => getLatestArtifact(r.id, "approved_plan")?.author === "system")
|
|
96
|
+
.map((r) => r.id);
|
|
97
|
+
const sentRunIds = recentDone
|
|
98
|
+
.filter((r) => getLatestArtifact(r.id, "send_receipt"))
|
|
99
|
+
.map((r) => r.id);
|
|
60
100
|
return {
|
|
61
101
|
planReviewCount: awaitingPlanReview.length,
|
|
62
102
|
resultReviewCount: resultReviewRuns.length,
|
|
@@ -68,6 +108,11 @@ export function getSnapshot() {
|
|
|
68
108
|
resultReviewRuns,
|
|
69
109
|
recentDone,
|
|
70
110
|
awaitingPlanReview,
|
|
111
|
+
awaitingAnswerRuns,
|
|
112
|
+
openQuestionCounts,
|
|
113
|
+
pendingSendCounts,
|
|
114
|
+
sentRunIds,
|
|
115
|
+
autoApprovedRunIds,
|
|
71
116
|
runs: all,
|
|
72
117
|
agentDeckOnline: false,
|
|
73
118
|
agents: [],
|
|
@@ -195,6 +240,49 @@ export async function kickRun(run) {
|
|
|
195
240
|
}
|
|
196
241
|
await dispatch();
|
|
197
242
|
}
|
|
243
|
+
function priorQuestionRounds(runId, currentTriageArtifactId) {
|
|
244
|
+
return listArtifacts(runId).filter((a) => {
|
|
245
|
+
if (a.kind !== "plan_triage" || a.id === currentTriageArtifactId || !a.contentJson)
|
|
246
|
+
return false;
|
|
247
|
+
try {
|
|
248
|
+
const c = JSON.parse(a.contentJson);
|
|
249
|
+
return (c.questions?.length ?? 0) > 0;
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
}).length;
|
|
255
|
+
}
|
|
256
|
+
/** Self-triage gate after a plan draft persists (PRD F2). Exported for tests. */
|
|
257
|
+
export function applyPlanGate(run) {
|
|
258
|
+
const art = getLatestArtifact(run.id, "plan_triage");
|
|
259
|
+
if (!art?.contentJson)
|
|
260
|
+
return "await_review";
|
|
261
|
+
let triage;
|
|
262
|
+
try {
|
|
263
|
+
triage = JSON.parse(art.contentJson);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return "await_review";
|
|
267
|
+
}
|
|
268
|
+
const decision = planGateDecision({
|
|
269
|
+
triage,
|
|
270
|
+
priorQuestionRounds: priorQuestionRounds(run.id, art.id),
|
|
271
|
+
});
|
|
272
|
+
if (decision !== "auto_approve")
|
|
273
|
+
return decision;
|
|
274
|
+
const fresh = getRun(run.id);
|
|
275
|
+
if (!fresh || fresh.status !== "plan_pending")
|
|
276
|
+
return "await_review";
|
|
277
|
+
const draft = getLatestArtifact(run.id, "draft_plan");
|
|
278
|
+
if (!draft?.contentJson)
|
|
279
|
+
return "await_review";
|
|
280
|
+
const plan = JSON.parse(draft.contentJson);
|
|
281
|
+
addArtifact(run.id, "approved_plan", { ...plan, autoApproved: true, rationale: triage.rationale }, "system");
|
|
282
|
+
transitionRun(run.id, "plan_approved");
|
|
283
|
+
void forceDispatch();
|
|
284
|
+
return "auto_approve";
|
|
285
|
+
}
|
|
198
286
|
export async function draftPlan(run, opts) {
|
|
199
287
|
if (run.status === "queued") {
|
|
200
288
|
transitionRun(run.id, "plan_pending");
|
|
@@ -208,7 +296,7 @@ export async function draftPlan(run, opts) {
|
|
|
208
296
|
}
|
|
209
297
|
const rt = runtimeFor(updated);
|
|
210
298
|
try {
|
|
211
|
-
const result = await runAgent(updated, "plan");
|
|
299
|
+
const result = await runAgent(updated, "plan", opts?.revise);
|
|
212
300
|
const persisted = persistRunOutput({
|
|
213
301
|
run: updated,
|
|
214
302
|
phase: "plan",
|
|
@@ -225,6 +313,18 @@ export async function draftPlan(run, opts) {
|
|
|
225
313
|
}
|
|
226
314
|
if (persisted.planMarkdown) {
|
|
227
315
|
addArtifact(updated.id, "draft_plan", { markdown: persisted.planMarkdown, sessionId: persisted.sessionId }, "agent", result.logPath);
|
|
316
|
+
const triage = persisted.planTriage;
|
|
317
|
+
if (triage) {
|
|
318
|
+
addArtifact(updated.id, "plan_triage", {
|
|
319
|
+
verdict: triage.verdict,
|
|
320
|
+
rationale: triage.rationale,
|
|
321
|
+
questions: triage.questions,
|
|
322
|
+
sessionId: persisted.sessionId,
|
|
323
|
+
parseFallback: triage.parseFallback,
|
|
324
|
+
consumed: false,
|
|
325
|
+
}, "agent");
|
|
326
|
+
applyPlanGate(getRun(run.id));
|
|
327
|
+
}
|
|
228
328
|
}
|
|
229
329
|
else if (!opts?.replace) {
|
|
230
330
|
addArtifact(updated.id, "feedback", { error: "Agent did not return a plan" }, "system");
|
|
@@ -245,6 +345,80 @@ export async function draftPlan(run, opts) {
|
|
|
245
345
|
await notify();
|
|
246
346
|
return getRun(run.id);
|
|
247
347
|
}
|
|
348
|
+
/** Answer open plan questions (PRD F3). Structured answers approve + dispatch; free-form revises the plan. */
|
|
349
|
+
export function submitPlanAnswers(runId, input, opts) {
|
|
350
|
+
const run = getRun(runId);
|
|
351
|
+
if (!run)
|
|
352
|
+
return { ok: false, code: 404, error: "Not found" };
|
|
353
|
+
if (run.status !== "plan_pending") {
|
|
354
|
+
return { ok: false, code: 409, error: `No open questions — run is ${run.status}` };
|
|
355
|
+
}
|
|
356
|
+
const triageArt = getLatestArtifact(runId, "plan_triage");
|
|
357
|
+
if (!triageArt?.contentJson)
|
|
358
|
+
return { ok: false, code: 409, error: "No open questions" };
|
|
359
|
+
let triage;
|
|
360
|
+
try {
|
|
361
|
+
triage = JSON.parse(triageArt.contentJson);
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
return { ok: false, code: 409, error: "No open questions" };
|
|
365
|
+
}
|
|
366
|
+
if (triage.consumed || triage.questions.length === 0) {
|
|
367
|
+
return { ok: false, code: 409, error: "No open questions" };
|
|
368
|
+
}
|
|
369
|
+
const priorAnswers = getLatestArtifact(runId, "plan_answers");
|
|
370
|
+
if (priorAnswers && priorAnswers.createdAt > triageArt.createdAt) {
|
|
371
|
+
return { ok: false, code: 409, error: "Questions already answered" };
|
|
372
|
+
}
|
|
373
|
+
const expected = triage.questions.map((q) => q.id).sort().join(",");
|
|
374
|
+
const got = input.answers.map((a) => a.questionId).sort().join(",");
|
|
375
|
+
if (expected !== got) {
|
|
376
|
+
return { ok: false, code: 400, error: "Answers must cover every open question exactly once" };
|
|
377
|
+
}
|
|
378
|
+
const freeForm = input.answers.some((a) => a.freeText);
|
|
379
|
+
const outcome = freeForm ? "redraft" : "approved";
|
|
380
|
+
addArtifact(runId, "plan_answers", { answers: input.answers, outcome, answeredAt: new Date().toISOString() }, "human");
|
|
381
|
+
if (!freeForm) {
|
|
382
|
+
// null/empty = "Default" — do not wipe a seeded execute_model; only set explicit picks
|
|
383
|
+
if (input.executeModel?.trim()) {
|
|
384
|
+
updateRunFields(runId, { execute_model: input.executeModel.trim() });
|
|
385
|
+
}
|
|
386
|
+
if (input.executeBudget !== undefined) {
|
|
387
|
+
patchRunPhaseBudget(runId, "execute", input.executeBudget);
|
|
388
|
+
}
|
|
389
|
+
markPlanTriageConsumed(runId);
|
|
390
|
+
try {
|
|
391
|
+
transitionRun(runId, "plan_approved");
|
|
392
|
+
}
|
|
393
|
+
catch (e) {
|
|
394
|
+
return { ok: false, code: 409, error: String(e) };
|
|
395
|
+
}
|
|
396
|
+
const draft = getLatestArtifact(runId, "draft_plan");
|
|
397
|
+
const plan = draft?.contentJson ? JSON.parse(draft.contentJson) : { markdown: "" };
|
|
398
|
+
addArtifact(runId, "approved_plan", plan, "human");
|
|
399
|
+
void forceDispatch();
|
|
400
|
+
return { ok: true, outcome, run: getRun(runId) };
|
|
401
|
+
}
|
|
402
|
+
(opts?.onRedraft ?? scheduleRevisePlan)(getRun(runId), triage, input.answers);
|
|
403
|
+
return { ok: true, outcome, run: getRun(runId) };
|
|
404
|
+
}
|
|
405
|
+
function scheduleRevisePlan(run, triage, answers) {
|
|
406
|
+
if (activePlanDrafts.has(run.id))
|
|
407
|
+
return;
|
|
408
|
+
activePlanDrafts.add(run.id);
|
|
409
|
+
void draftPlan(run, {
|
|
410
|
+
replace: true,
|
|
411
|
+
revise: {
|
|
412
|
+
resumeSessionId: triage.sessionId,
|
|
413
|
+
prompt: buildPlanRevisePrompt(run, triage.questions, answers),
|
|
414
|
+
},
|
|
415
|
+
})
|
|
416
|
+
.catch((e) => console.error("[plan-revise]", run.id, e))
|
|
417
|
+
.finally(() => {
|
|
418
|
+
activePlanDrafts.delete(run.id);
|
|
419
|
+
notify().catch(console.error);
|
|
420
|
+
});
|
|
421
|
+
}
|
|
248
422
|
/** Fire-and-forget: human requested a new agent plan (replaces draft). */
|
|
249
423
|
export function scheduleRedraft(run) {
|
|
250
424
|
if (activePlanDrafts.has(run.id))
|
|
@@ -0,0 +1,55 @@
|
|
|
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-gate-"));
|
|
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 } = await import("../repository/runs.js");
|
|
12
|
+
const { applyPlanGate, getSnapshot } = await import("./dispatcher.js");
|
|
13
|
+
const QUESTIONS = [
|
|
14
|
+
{ id: "q1", question: "Which approach?", options: [{ label: "A" }, { label: "B" }] },
|
|
15
|
+
];
|
|
16
|
+
before(() => {
|
|
17
|
+
migrate();
|
|
18
|
+
updateAgent(BUILTIN_AGENT_CLAUDE_ID, { workspaceRoot: process.env.AGENT_DEALER_HOME });
|
|
19
|
+
});
|
|
20
|
+
function seedRun(triage) {
|
|
21
|
+
const run = createRun({
|
|
22
|
+
title: "Gate test",
|
|
23
|
+
taskCategory: "other",
|
|
24
|
+
status: "plan_pending",
|
|
25
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
26
|
+
});
|
|
27
|
+
addArtifact(run.id, "draft_plan", { markdown: "# Plan" }, "agent");
|
|
28
|
+
addArtifact(run.id, "plan_triage", triage, "agent");
|
|
29
|
+
return getRun(run.id);
|
|
30
|
+
}
|
|
31
|
+
test("trivial triage auto-approves: system approved_plan + plan_approved status", () => {
|
|
32
|
+
const run = seedRun({ verdict: "trivial", rationale: "tiny", questions: [], parseFallback: false, consumed: false });
|
|
33
|
+
assert.equal(applyPlanGate(run), "auto_approve");
|
|
34
|
+
assert.equal(getRun(run.id).status, "plan_approved");
|
|
35
|
+
const approved = getLatestArtifact(run.id, "approved_plan");
|
|
36
|
+
assert.equal(approved.author, "system");
|
|
37
|
+
assert.match(approved.contentJson, /tiny/);
|
|
38
|
+
});
|
|
39
|
+
test("questions await answers and leave status untouched", () => {
|
|
40
|
+
const run = seedRun({ verdict: "needs_review", rationale: "r", questions: QUESTIONS, parseFallback: false, consumed: false });
|
|
41
|
+
assert.equal(applyPlanGate(run), "await_answers");
|
|
42
|
+
assert.equal(getRun(run.id).status, "plan_pending");
|
|
43
|
+
});
|
|
44
|
+
test("third question round goes to manual review", () => {
|
|
45
|
+
const run = seedRun({ verdict: "needs_review", rationale: "r1", questions: QUESTIONS, parseFallback: false, consumed: false });
|
|
46
|
+
addArtifact(run.id, "plan_triage", { verdict: "needs_review", rationale: "r2", questions: QUESTIONS, parseFallback: false, consumed: false }, "agent");
|
|
47
|
+
addArtifact(run.id, "plan_triage", { verdict: "needs_review", rationale: "r3", questions: QUESTIONS, parseFallback: false, consumed: false }, "agent");
|
|
48
|
+
assert.equal(applyPlanGate(getRun(run.id)), "await_review");
|
|
49
|
+
});
|
|
50
|
+
test("snapshot exposes awaitingAnswerRuns, openQuestionCounts, autoApprovedRunIds", () => {
|
|
51
|
+
const snap = getSnapshot();
|
|
52
|
+
assert.equal(Array.isArray(snap.awaitingAnswerRuns), true);
|
|
53
|
+
assert.equal(typeof snap.openQuestionCounts, "object");
|
|
54
|
+
assert.equal(Array.isArray(snap.autoApprovedRunIds), true);
|
|
55
|
+
});
|