agent-dealer 0.1.3 → 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/approve-deliver.js +62 -0
- package/bundle/server/dist/queue/dispatcher.js +12 -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/routes/index.js +7 -9
- package/bundle/server/dist/runners/claude-args.js +24 -0
- package/bundle/server/dist/runners/claude.js +3 -13
- package/bundle/server/dist/runners/claude.test.js +58 -0
- package/bundle/server/dist/runners/persist.js +31 -5
- package/bundle/server/dist/runners/prompts.js +43 -8
- package/bundle/server/dist/runners/prompts.test.js +29 -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/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/index.d.ts +53 -46
- package/bundle/shared/dist/index.js +4 -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/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,108 @@
|
|
|
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-send-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, transitionRun, updateRunFields, } = await import("../repository/runs.js");
|
|
12
|
+
const { approveRunWithDeliver } = await import("./approve-deliver.js");
|
|
13
|
+
const { rejectPendingOutboundDrafts, pendingSendCount } = await import("../repository/outbound-drafts.js");
|
|
14
|
+
const { getSnapshot } = await import("./dispatcher.js");
|
|
15
|
+
const TOOL_CALL = {
|
|
16
|
+
serviceName: "34eb6c24-f151-4da2-8db8-d6996aa296be",
|
|
17
|
+
toolName: "chat_postMessage",
|
|
18
|
+
arguments: { channel: "C1", text: "Hello gate test" },
|
|
19
|
+
};
|
|
20
|
+
const DRAFT_CONTENT = {
|
|
21
|
+
draft: {
|
|
22
|
+
actionType: "slack_message",
|
|
23
|
+
summary: { target: "#test", body: "Hello gate test" },
|
|
24
|
+
toolCall: TOOL_CALL,
|
|
25
|
+
},
|
|
26
|
+
status: "pending",
|
|
27
|
+
};
|
|
28
|
+
before(() => {
|
|
29
|
+
migrate();
|
|
30
|
+
updateAgent(BUILTIN_AGENT_CLAUDE_ID, { workspaceRoot: process.env.AGENT_DEALER_HOME });
|
|
31
|
+
});
|
|
32
|
+
function seedReviewRun(withDraft = true) {
|
|
33
|
+
const run = createRun({
|
|
34
|
+
title: "Send gate test",
|
|
35
|
+
taskCategory: "communication",
|
|
36
|
+
status: "plan_pending",
|
|
37
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
38
|
+
});
|
|
39
|
+
updateRunFields(run.id, { deck_id: "6e825b59-13de-4ddd-ab7e-55ab5a1c279a" });
|
|
40
|
+
transitionRun(run.id, "plan_approved");
|
|
41
|
+
transitionRun(run.id, "running");
|
|
42
|
+
transitionRun(run.id, "review");
|
|
43
|
+
if (withDraft) {
|
|
44
|
+
addArtifact(run.id, "slack_draft", DRAFT_CONTENT, "agent");
|
|
45
|
+
}
|
|
46
|
+
return getRun(run.id);
|
|
47
|
+
}
|
|
48
|
+
test("pendingSendCount is 1 when draft pending", () => {
|
|
49
|
+
const run = seedReviewRun(true);
|
|
50
|
+
assert.equal(pendingSendCount(run.id), 1);
|
|
51
|
+
});
|
|
52
|
+
test("approve without draft transitions to done", async () => {
|
|
53
|
+
const run = seedReviewRun(false);
|
|
54
|
+
const res = await approveRunWithDeliver(run.id);
|
|
55
|
+
assert.equal(res.ok, true);
|
|
56
|
+
assert.equal(res.ok && res.delivered, false);
|
|
57
|
+
assert.equal(getRun(run.id).status, "done");
|
|
58
|
+
});
|
|
59
|
+
test("approve with mock deliver sends byte-identical payload and creates receipt", async () => {
|
|
60
|
+
const run = seedReviewRun(true);
|
|
61
|
+
let captured = null;
|
|
62
|
+
const res = await approveRunWithDeliver(run.id, {
|
|
63
|
+
deliver: async (_deckId, toolCall) => {
|
|
64
|
+
captured = toolCall;
|
|
65
|
+
return { toolResult: { ok: true }, permalink: "https://slack.example/msg/1" };
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
assert.equal(res.ok, true);
|
|
69
|
+
assert.equal(res.ok && res.delivered, true);
|
|
70
|
+
assert.deepEqual(captured, TOOL_CALL);
|
|
71
|
+
assert.equal(getRun(run.id).status, "done");
|
|
72
|
+
assert.ok(getLatestArtifact(run.id, "send_receipt"));
|
|
73
|
+
const draftArt = getLatestArtifact(run.id, "slack_draft");
|
|
74
|
+
assert.match(draftArt.contentJson, /"status":"sent"/);
|
|
75
|
+
});
|
|
76
|
+
test("deliver failure keeps run in review with pending draft", async () => {
|
|
77
|
+
const run = seedReviewRun(true);
|
|
78
|
+
const res = await approveRunWithDeliver(run.id, {
|
|
79
|
+
deliver: async () => {
|
|
80
|
+
throw new Error("deck down");
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
assert.equal(res.ok, false);
|
|
84
|
+
assert.equal(!res.ok && res.code, 502);
|
|
85
|
+
assert.equal(getRun(run.id).status, "review");
|
|
86
|
+
assert.equal(pendingSendCount(run.id), 1);
|
|
87
|
+
});
|
|
88
|
+
test("proxy success:false from deck is treated as deliver failure", async () => {
|
|
89
|
+
const run = seedReviewRun(true);
|
|
90
|
+
const res = await approveRunWithDeliver(run.id, {
|
|
91
|
+
deliver: async () => {
|
|
92
|
+
throw new Error("Service not found");
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
assert.equal(res.ok, false);
|
|
96
|
+
assert.equal(getRun(run.id).status, "review");
|
|
97
|
+
});
|
|
98
|
+
test("retry rejects pending draft", () => {
|
|
99
|
+
const run = seedReviewRun(true);
|
|
100
|
+
rejectPendingOutboundDrafts(run.id);
|
|
101
|
+
assert.equal(pendingSendCount(run.id), 0);
|
|
102
|
+
const draftArt = getLatestArtifact(run.id, "slack_draft");
|
|
103
|
+
assert.match(draftArt.contentJson, /"status":"rejected"/);
|
|
104
|
+
});
|
|
105
|
+
test("snapshot exposes pendingSendCounts", () => {
|
|
106
|
+
const snap = getSnapshot();
|
|
107
|
+
assert.equal(typeof snap.pendingSendCounts, "object");
|
|
108
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { isOutboundDraftKind } from "@agent-dealer/shared";
|
|
2
|
+
import { getDb } from "../db/index.js";
|
|
3
|
+
import { listArtifacts } from "./runs.js";
|
|
4
|
+
export function getPendingOutboundDraft(runId) {
|
|
5
|
+
const arts = listArtifacts(runId).filter((a) => isOutboundDraftKind(a.kind) && a.contentJson);
|
|
6
|
+
for (let i = arts.length - 1; i >= 0; i--) {
|
|
7
|
+
const art = arts[i];
|
|
8
|
+
try {
|
|
9
|
+
const content = JSON.parse(art.contentJson);
|
|
10
|
+
if (content.status === "pending") {
|
|
11
|
+
return { artifact: art, content };
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// skip
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
export function pendingSendCount(runId) {
|
|
21
|
+
return getPendingOutboundDraft(runId) ? 1 : 0;
|
|
22
|
+
}
|
|
23
|
+
export function rejectPendingOutboundDrafts(runId) {
|
|
24
|
+
const now = new Date().toISOString();
|
|
25
|
+
const db = getDb();
|
|
26
|
+
for (const art of listArtifacts(runId)) {
|
|
27
|
+
if (!isOutboundDraftKind(art.kind) || !art.contentJson)
|
|
28
|
+
continue;
|
|
29
|
+
try {
|
|
30
|
+
const content = JSON.parse(art.contentJson);
|
|
31
|
+
if (content.status !== "pending")
|
|
32
|
+
continue;
|
|
33
|
+
const next = { ...content, status: "rejected", rejectedAt: now };
|
|
34
|
+
db.prepare("UPDATE artifacts SET content_json = ? WHERE id = ?").run(JSON.stringify(next), art.id);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// skip
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Atomic pending → sent transition. Returns false if not pending. */
|
|
42
|
+
export function markOutboundDraftSent(artifactId) {
|
|
43
|
+
const db = getDb();
|
|
44
|
+
const row = db.prepare("SELECT content_json FROM artifacts WHERE id = ?").get(artifactId);
|
|
45
|
+
if (!row?.content_json)
|
|
46
|
+
return false;
|
|
47
|
+
let content;
|
|
48
|
+
try {
|
|
49
|
+
content = JSON.parse(row.content_json);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
if (content.status !== "pending")
|
|
55
|
+
return false;
|
|
56
|
+
const next = { ...content, status: "sent", sentAt: new Date().toISOString() };
|
|
57
|
+
const result = db
|
|
58
|
+
.prepare("UPDATE artifacts SET content_json = ? WHERE id = ? AND content_json = ?")
|
|
59
|
+
.run(JSON.stringify(next), artifactId, row.content_json);
|
|
60
|
+
return result.changes === 1;
|
|
61
|
+
}
|
|
62
|
+
export const deliverInFlight = new Set();
|
|
@@ -5,6 +5,8 @@ import { addArtifact, createRun, findActiveByExternalId, getRun, listArtifacts,
|
|
|
5
5
|
import { createAgent, deleteAgent, getAgent, listAgents, updateAgent, } from "../repository/agents.js";
|
|
6
6
|
import { listAgentsWithHealth } from "../adapters/agent-health.js";
|
|
7
7
|
import { forceDispatch, getSnapshotAsync, kickRun, schedulePlanDraft, scheduleRedraft, scheduleReflect, submitPlanAnswers, subscribe, } from "../queue/dispatcher.js";
|
|
8
|
+
import { approveRunWithDeliver } from "../queue/approve-deliver.js";
|
|
9
|
+
import { rejectPendingOutboundDrafts } from "../repository/outbound-drafts.js";
|
|
8
10
|
import { fetchAgentDeckDecks, updatePlaybookBody } from "../adapters/agent-deck.js";
|
|
9
11
|
import { testAgentDeckConnection } from "../adapters/agent-deck.js";
|
|
10
12
|
import { getLinearIssue, listLinearCandidates, testLinearConnection, } from "../adapters/linear-inbox.js";
|
|
@@ -373,16 +375,11 @@ export async function registerRoutes(app) {
|
|
|
373
375
|
});
|
|
374
376
|
app.post("/api/runs/:id/approve", async (req, reply) => {
|
|
375
377
|
const { id } = req.params;
|
|
376
|
-
const
|
|
377
|
-
if (!
|
|
378
|
-
return reply.status(
|
|
379
|
-
if (run.status !== "review") {
|
|
380
|
-
return reply.status(400).send({ error: "Run must be in review" });
|
|
378
|
+
const result = await approveRunWithDeliver(id);
|
|
379
|
+
if (!result.ok) {
|
|
380
|
+
return reply.status(result.code).send({ error: result.error });
|
|
381
381
|
}
|
|
382
|
-
|
|
383
|
-
syncLinearForRun(updated, "done").catch((e) => console.error("[linear-sync] done:", e));
|
|
384
|
-
scheduleReflect(updated, { trigger: "approve" });
|
|
385
|
-
return updated;
|
|
382
|
+
return result.run;
|
|
386
383
|
});
|
|
387
384
|
app.post("/api/runs/:id/retry", async (req, reply) => {
|
|
388
385
|
const { id } = req.params;
|
|
@@ -398,6 +395,7 @@ export async function registerRoutes(app) {
|
|
|
398
395
|
}
|
|
399
396
|
const input = RetryRunInput.parse(req.body);
|
|
400
397
|
addArtifact(id, "feedback", { markdown: input.feedback }, "human");
|
|
398
|
+
rejectPendingOutboundDrafts(id);
|
|
401
399
|
const retry = createRun({
|
|
402
400
|
title: run.title,
|
|
403
401
|
description: run.description ?? undefined,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { getTemporalOutputDir } from "../paths.js";
|
|
2
|
+
export const DENY_SEND_TOOL = "mcp__agent-deck__call_service_tool";
|
|
3
|
+
const DECK_READ_TOOLS = "mcp__agent-deck__get_playbook,mcp__agent-deck__get_bound_deck,mcp__agent-deck__bind_workspace,mcp__agent-deck__list_service_tools";
|
|
4
|
+
const PLAN_REFLECT_TOOLS = `Read,Glob,Grep,Skill,${DECK_READ_TOOLS}`;
|
|
5
|
+
function executeToolsForCategory(category) {
|
|
6
|
+
const base = ["Read", "Write", "Edit", "Glob", "Grep", "Skill", DECK_READ_TOOLS];
|
|
7
|
+
if (category !== "communication" && category !== "email") {
|
|
8
|
+
base.splice(5, 0, "Bash");
|
|
9
|
+
}
|
|
10
|
+
return base.join(",");
|
|
11
|
+
}
|
|
12
|
+
/** Build claude -p CLI args for a phase (excluding prompt, model, resume, mcp-config, budget). */
|
|
13
|
+
export function buildClaudePhaseArgs(run, mode) {
|
|
14
|
+
const args = ["--output-format", "stream-json", "--verbose"];
|
|
15
|
+
if (mode === "plan" || mode === "reflect") {
|
|
16
|
+
args.push("--allowedTools", PLAN_REFLECT_TOOLS);
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
args.push("--add-dir", getTemporalOutputDir());
|
|
20
|
+
args.push("--allowedTools", executeToolsForCategory(run.taskCategory));
|
|
21
|
+
}
|
|
22
|
+
args.push("--disallowedTools", DENY_SEND_TOOL);
|
|
23
|
+
return args;
|
|
24
|
+
}
|
|
@@ -3,10 +3,11 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { buildExecutionPrompt, buildExecutionContinuationPrompt, buildPlanPrompt, buildReflectPrompt, workspaceForRun } from "./prompts.js";
|
|
5
5
|
import { humanFeedbackText, lineageParentExecuteSessionId } from "./run-context.js";
|
|
6
|
-
import { getTemporalLogsDir
|
|
6
|
+
import { getTemporalLogsDir } from "../paths.js";
|
|
7
7
|
import { resolveClaudeBin, resolveCursorBin } from "../cli-env.js";
|
|
8
8
|
import { resolveBudgetForPhase, resolveModelForPhase, getRun } from "../repository/runs.js";
|
|
9
9
|
import { budgetCliArgs } from "@agent-dealer/shared";
|
|
10
|
+
import { buildClaudePhaseArgs } from "./claude-args.js";
|
|
10
11
|
async function spawnCli(cmd, args, cwd) {
|
|
11
12
|
return new Promise((resolve, reject) => {
|
|
12
13
|
const chunks = [];
|
|
@@ -50,19 +51,8 @@ export async function runClaude(run, mode = "execute", model, opts) {
|
|
|
50
51
|
"--mcp-config",
|
|
51
52
|
mcpConfig,
|
|
52
53
|
...budgetCliArgs(phaseBudget),
|
|
53
|
-
|
|
54
|
-
"stream-json",
|
|
55
|
-
"--verbose",
|
|
54
|
+
...buildClaudePhaseArgs(run, mode),
|
|
56
55
|
];
|
|
57
|
-
if (mode === "plan") {
|
|
58
|
-
args.push("--allowedTools", "Read,Glob,Grep,Skill,mcp__agent-deck__get_playbook,mcp__agent-deck__get_bound_deck,mcp__agent-deck__bind_workspace");
|
|
59
|
-
}
|
|
60
|
-
else if (mode === "reflect") {
|
|
61
|
-
args.push("--allowedTools", "Read,Glob,Grep,Skill,mcp__agent-deck__get_playbook,mcp__agent-deck__get_bound_deck,mcp__agent-deck__bind_workspace");
|
|
62
|
-
}
|
|
63
|
-
else {
|
|
64
|
-
args.push("--add-dir", getTemporalOutputDir(), "--allowedTools", "Read,Write,Edit,Glob,Grep,Bash,Skill,mcp__agent-deck__get_playbook,mcp__agent-deck__get_bound_deck,mcp__agent-deck__bind_workspace");
|
|
65
|
-
}
|
|
66
56
|
const { exitCode, transcript } = await spawnCli(resolveClaudeBin(), args, workspaceForRun(run));
|
|
67
57
|
if (transcript)
|
|
68
58
|
fs.writeFileSync(logPath, transcript);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { buildClaudePhaseArgs, DENY_SEND_TOOL } from "./claude-args.js";
|
|
4
|
+
const baseRun = {
|
|
5
|
+
id: "00000000-0000-4000-a000-000000000099",
|
|
6
|
+
source: "manual",
|
|
7
|
+
externalId: null,
|
|
8
|
+
externalLabel: null,
|
|
9
|
+
title: "Test",
|
|
10
|
+
description: null,
|
|
11
|
+
repo: null,
|
|
12
|
+
artifactWorkspace: null,
|
|
13
|
+
agentId: null,
|
|
14
|
+
agentName: null,
|
|
15
|
+
deckId: null,
|
|
16
|
+
deckName: null,
|
|
17
|
+
playbookId: null,
|
|
18
|
+
runtime: "claude_code",
|
|
19
|
+
planModel: null,
|
|
20
|
+
executeModel: null,
|
|
21
|
+
status: "running",
|
|
22
|
+
lineageId: null,
|
|
23
|
+
acceptanceCriteria: null,
|
|
24
|
+
approvalGatesJson: null,
|
|
25
|
+
budgetJson: null,
|
|
26
|
+
createdAt: new Date().toISOString(),
|
|
27
|
+
updatedAt: new Date().toISOString(),
|
|
28
|
+
};
|
|
29
|
+
function runWithCategory(taskCategory) {
|
|
30
|
+
return { ...baseRun, taskCategory };
|
|
31
|
+
}
|
|
32
|
+
function hasDisallowSend(args) {
|
|
33
|
+
const i = args.indexOf("--disallowedTools");
|
|
34
|
+
return i >= 0 && args[i + 1] === DENY_SEND_TOOL;
|
|
35
|
+
}
|
|
36
|
+
test("all phases deny call_service_tool", () => {
|
|
37
|
+
for (const mode of ["plan", "execute", "reflect"]) {
|
|
38
|
+
const args = buildClaudePhaseArgs(runWithCategory("code"), mode);
|
|
39
|
+
assert.equal(hasDisallowSend(args), true, mode);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
test("execute allowlist includes list_service_tools", () => {
|
|
43
|
+
const args = buildClaudePhaseArgs(runWithCategory("code"), "execute");
|
|
44
|
+
const i = args.indexOf("--allowedTools");
|
|
45
|
+
assert.match(args[i + 1], /list_service_tools/);
|
|
46
|
+
});
|
|
47
|
+
test("communication and email execute omit Bash", () => {
|
|
48
|
+
for (const cat of ["communication", "email"]) {
|
|
49
|
+
const args = buildClaudePhaseArgs(runWithCategory(cat), "execute");
|
|
50
|
+
const i = args.indexOf("--allowedTools");
|
|
51
|
+
assert.doesNotMatch(args[i + 1], /\bBash\b/);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
test("code execute retains Bash", () => {
|
|
55
|
+
const args = buildClaudePhaseArgs(runWithCategory("code"), "execute");
|
|
56
|
+
const i = args.indexOf("--allowedTools");
|
|
57
|
+
assert.match(args[i + 1], /\bBash\b/);
|
|
58
|
+
});
|