@stigmer/runner 3.12.1 → 3.12.2
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/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-cursor/capture-flow.d.ts +5 -4
- package/dist/activities/execute-cursor/capture-flow.js +5 -4
- package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
- package/dist/activities/execute-cursor/cas-observations.d.ts +10 -4
- package/dist/activities/execute-cursor/cas-observations.js +10 -4
- package/dist/activities/execute-cursor/cas-observations.js.map +1 -1
- package/dist/activities/execute-cursor/hook-script.js +39 -13
- package/dist/activities/execute-cursor/hook-script.js.map +1 -1
- package/dist/activities/execute-cursor/skill-resolver.d.ts +8 -0
- package/dist/activities/execute-cursor/skill-resolver.js +77 -26
- package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
- package/dist/activities/execute-deep-agent/cas-capture-backend.d.ts +3 -1
- package/dist/activities/execute-deep-agent/cas-capture-backend.js +3 -1
- package/dist/activities/execute-deep-agent/cas-capture-backend.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.js +8 -0
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-wiring.js +16 -9
- package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
- package/dist/activities/generate-session-subject.d.ts +80 -0
- package/dist/activities/generate-session-subject.js +283 -0
- package/dist/activities/generate-session-subject.js.map +1 -0
- package/dist/client/stigmer-client.d.ts +8 -0
- package/dist/client/stigmer-client.js +10 -0
- package/dist/client/stigmer-client.js.map +1 -1
- package/dist/middleware/approval-gate.d.ts +25 -10
- package/dist/middleware/approval-gate.js +33 -14
- package/dist/middleware/approval-gate.js.map +1 -1
- package/dist/middleware/otel-spans.d.ts +2 -1
- package/dist/middleware/otel-spans.js +2 -1
- package/dist/middleware/otel-spans.js.map +1 -1
- package/dist/runner-manager.js +3 -1
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.js +3 -1
- package/dist/runner.js.map +1 -1
- package/dist/shared/attachment-vision.js +9 -0
- package/dist/shared/attachment-vision.js.map +1 -1
- package/dist/shared/mcp-manager.js +8 -0
- package/dist/shared/mcp-manager.js.map +1 -1
- package/package.json +2 -2
- package/src/activities/__tests__/generate-session-subject.test.ts +348 -0
- package/src/activities/execute-cursor/__tests__/hook-script.test.ts +60 -9
- package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +163 -7
- package/src/activities/execute-cursor/capture-flow.ts +5 -4
- package/src/activities/execute-cursor/cas-observations.ts +10 -4
- package/src/activities/execute-cursor/hook-script.ts +39 -13
- package/src/activities/execute-cursor/skill-resolver.ts +98 -31
- package/src/activities/execute-deep-agent/cas-capture-backend.ts +3 -1
- package/src/activities/execute-deep-agent/setup.ts +8 -0
- package/src/activities/execute-deep-agent/subagent-wiring.ts +16 -9
- package/src/activities/generate-session-subject.ts +370 -0
- package/src/client/stigmer-client.ts +11 -0
- package/src/middleware/__tests__/approval-gate.test.ts +130 -1
- package/src/middleware/approval-gate.ts +58 -24
- package/src/middleware/otel-spans.ts +2 -1
- package/src/runner-manager.ts +3 -0
- package/src/runner.ts +3 -0
- package/src/shared/__tests__/attachment-vision.test.ts +4 -0
- package/src/shared/__tests__/mcp-manager.test.ts +7 -2
- package/src/shared/attachment-vision.ts +9 -0
- package/src/shared/filereview/__tests__/capture.test.ts +48 -0
- package/src/shared/mcp-manager.ts +8 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the GenerateSessionSubject activity core.
|
|
3
|
+
*
|
|
4
|
+
* The LLM seam (model registry + chat model) is module-mocked; the backend
|
|
5
|
+
* client is injected through SessionSubjectClient, so every skip branch,
|
|
6
|
+
* the agent-resolution chain, and both fallback classes are pinned without
|
|
7
|
+
* Temporal or network coupling. Behavioral contract mirrors the cloud
|
|
8
|
+
* GenerateSessionSubjectActivityImpl (see the activity header).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
12
|
+
import { ConnectError, Code } from "@connectrpc/connect";
|
|
13
|
+
import type { AgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
14
|
+
import type { Session } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
|
|
15
|
+
import type { Agent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
|
|
16
|
+
import type { AgentInstance } from "@stigmer/protos/ai/stigmer/agentic/agentinstance/v1/api_pb";
|
|
17
|
+
|
|
18
|
+
vi.mock("../../shared/model-registry.js", () => ({
|
|
19
|
+
getSummarizationModel: vi.fn(async (primary: string) => primary),
|
|
20
|
+
}));
|
|
21
|
+
vi.mock("../../shared/model-client.js", () => ({
|
|
22
|
+
buildChatModel: vi.fn(),
|
|
23
|
+
}));
|
|
24
|
+
vi.mock("../../shared/llm-backend.js", () => ({
|
|
25
|
+
checkDirectCredentials: vi.fn(() => null),
|
|
26
|
+
}));
|
|
27
|
+
vi.mock("../../shared/llm-proxy.js", () => ({
|
|
28
|
+
tryInferProvider: vi.fn(() => "anthropic"),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
AUTO_CREATED_SUBJECT,
|
|
33
|
+
generateSessionSubject,
|
|
34
|
+
resolveAgentId,
|
|
35
|
+
heuristicSubject,
|
|
36
|
+
cleanSubject,
|
|
37
|
+
buildUserPrompt,
|
|
38
|
+
type SessionSubjectClient,
|
|
39
|
+
type GenerateSessionSubjectOptions,
|
|
40
|
+
} from "../generate-session-subject.js";
|
|
41
|
+
import { buildChatModel } from "../../shared/model-client.js";
|
|
42
|
+
import { checkDirectCredentials } from "../../shared/llm-backend.js";
|
|
43
|
+
|
|
44
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
45
|
+
// Fixtures
|
|
46
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
const EXECUTION_ID = "aex_test123";
|
|
49
|
+
const SESSION_ID = "ses_test456";
|
|
50
|
+
const AGENT_ID = "agt_test789";
|
|
51
|
+
const INSTANCE_ID = "ain_test012";
|
|
52
|
+
|
|
53
|
+
function fakeExecution(overrides: Record<string, unknown> = {}): AgentExecution {
|
|
54
|
+
return {
|
|
55
|
+
spec: {
|
|
56
|
+
sessionId: SESSION_ID,
|
|
57
|
+
agentId: AGENT_ID,
|
|
58
|
+
message: "Explain how database indexing works for PostgreSQL",
|
|
59
|
+
...overrides,
|
|
60
|
+
},
|
|
61
|
+
} as unknown as AgentExecution;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function fakeSession(subject: string = AUTO_CREATED_SUBJECT, agentInstanceId = ""): Session {
|
|
65
|
+
return {
|
|
66
|
+
spec: { subject, agentInstanceId },
|
|
67
|
+
} as unknown as Session;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function fakeAgent(): Agent {
|
|
71
|
+
return {
|
|
72
|
+
metadata: { name: "test-agent" },
|
|
73
|
+
spec: { description: "A helpful test assistant" },
|
|
74
|
+
} as unknown as Agent;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function fakeInstance(agentId: string): AgentInstance {
|
|
78
|
+
return { spec: { agentId } } as unknown as AgentInstance;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function notFound(): ConnectError {
|
|
82
|
+
return new ConnectError("not found", Code.NotFound);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface ClientBehavior {
|
|
86
|
+
execution?: AgentExecution | Error;
|
|
87
|
+
session?: Session | Error;
|
|
88
|
+
agent?: Agent | Error;
|
|
89
|
+
instance?: AgentInstance | Error;
|
|
90
|
+
updateError?: Error;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function fakeClient(behavior: ClientBehavior = {}) {
|
|
94
|
+
const updated: Array<{ sessionId: string; subject: string }> = [];
|
|
95
|
+
const resolve = <T>(value: T | Error | undefined, fallback: T): Promise<T> => {
|
|
96
|
+
if (value instanceof Error) return Promise.reject(value);
|
|
97
|
+
return Promise.resolve(value ?? fallback);
|
|
98
|
+
};
|
|
99
|
+
const client: SessionSubjectClient = {
|
|
100
|
+
getExecution: vi.fn(() => resolve(behavior.execution, fakeExecution())),
|
|
101
|
+
getSession: vi.fn(() => resolve(behavior.session, fakeSession())),
|
|
102
|
+
getAgent: vi.fn(() => resolve(behavior.agent, fakeAgent())),
|
|
103
|
+
getAgentInstance: vi.fn(() => resolve(behavior.instance, fakeInstance(AGENT_ID))),
|
|
104
|
+
updateSessionSubject: vi.fn((sessionId: string, subject: string) => {
|
|
105
|
+
if (behavior.updateError) return Promise.reject(behavior.updateError);
|
|
106
|
+
updated.push({ sessionId, subject });
|
|
107
|
+
return Promise.resolve(fakeSession(subject));
|
|
108
|
+
}),
|
|
109
|
+
};
|
|
110
|
+
return { client, updated };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const OPTIONS: GenerateSessionSubjectOptions = {
|
|
114
|
+
proxyEndpoint: null,
|
|
115
|
+
stigmerToken: null,
|
|
116
|
+
primaryModel: "claude-sonnet-4.5",
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
function mockLlmReturning(content: unknown): void {
|
|
120
|
+
vi.mocked(buildChatModel).mockResolvedValue({
|
|
121
|
+
model: { invoke: vi.fn(async () => ({ content })) },
|
|
122
|
+
} as never);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
beforeEach(() => {
|
|
126
|
+
vi.mocked(buildChatModel).mockReset();
|
|
127
|
+
vi.mocked(checkDirectCredentials).mockReturnValue(null);
|
|
128
|
+
mockLlmReturning("PostgreSQL B-tree Indexing");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
132
|
+
// Happy path + persistence
|
|
133
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
describe("generateSessionSubject", () => {
|
|
136
|
+
it("replaces the sentinel subject with the LLM title", async () => {
|
|
137
|
+
const { client, updated } = fakeClient();
|
|
138
|
+
await generateSessionSubject(EXECUTION_ID, client, OPTIONS);
|
|
139
|
+
expect(updated).toEqual([{ sessionId: SESSION_ID, subject: "PostgreSQL B-tree Indexing" }]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("titles a session whose subject is empty (not just the sentinel)", async () => {
|
|
143
|
+
const { client, updated } = fakeClient({ session: fakeSession("") });
|
|
144
|
+
await generateSessionSubject(EXECUTION_ID, client, OPTIONS);
|
|
145
|
+
expect(updated).toHaveLength(1);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("passes executionId into the proxy header scope for billing attribution", async () => {
|
|
149
|
+
const { client } = fakeClient();
|
|
150
|
+
await generateSessionSubject(EXECUTION_ID, client, {
|
|
151
|
+
...OPTIONS,
|
|
152
|
+
proxyEndpoint: "https://proxy.example",
|
|
153
|
+
stigmerToken: "tok",
|
|
154
|
+
});
|
|
155
|
+
expect(vi.mocked(buildChatModel)).toHaveBeenCalledWith(
|
|
156
|
+
expect.objectContaining({ headerScope: { executionId: EXECUTION_ID } }),
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("swallows a persistence failure (non-critical contract)", async () => {
|
|
161
|
+
const { client } = fakeClient({ updateError: new Error("write refused") });
|
|
162
|
+
await expect(generateSessionSubject(EXECUTION_ID, client, OPTIONS)).resolves.toBeUndefined();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
166
|
+
// Skip branches (cloud-parity semantics)
|
|
167
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
it("skips when the subject was already set by a human", async () => {
|
|
170
|
+
const { client, updated } = fakeClient({ session: fakeSession("My renamed chat") });
|
|
171
|
+
await generateSessionSubject(EXECUTION_ID, client, OPTIONS);
|
|
172
|
+
expect(updated).toHaveLength(0);
|
|
173
|
+
expect(vi.mocked(buildChatModel)).not.toHaveBeenCalled();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("skips when the execution has no session id", async () => {
|
|
177
|
+
const { client, updated } = fakeClient({ execution: fakeExecution({ sessionId: "" }) });
|
|
178
|
+
await generateSessionSubject(EXECUTION_ID, client, OPTIONS);
|
|
179
|
+
expect(updated).toHaveLength(0);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("skips when the execution has no user message", async () => {
|
|
183
|
+
const { client, updated } = fakeClient({ execution: fakeExecution({ message: "" }) });
|
|
184
|
+
await generateSessionSubject(EXECUTION_ID, client, OPTIONS);
|
|
185
|
+
expect(updated).toHaveLength(0);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it.each([
|
|
189
|
+
["execution", { execution: notFound() }],
|
|
190
|
+
["session", { session: notFound() }],
|
|
191
|
+
["agent", { agent: notFound() }],
|
|
192
|
+
] as const)("skips (does not throw) when the %s is NOT_FOUND", async (_what, behavior) => {
|
|
193
|
+
const { client, updated } = fakeClient(behavior);
|
|
194
|
+
await expect(generateSessionSubject(EXECUTION_ID, client, OPTIONS)).resolves.toBeUndefined();
|
|
195
|
+
expect(updated).toHaveLength(0);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("propagates non-NOT_FOUND lookup failures as activity failures", async () => {
|
|
199
|
+
const { client } = fakeClient({
|
|
200
|
+
execution: new ConnectError("backend down", Code.Unavailable),
|
|
201
|
+
});
|
|
202
|
+
await expect(generateSessionSubject(EXECUTION_ID, client, OPTIONS)).rejects.toThrow(
|
|
203
|
+
"backend down",
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
208
|
+
// Fallbacks
|
|
209
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
it("falls back to the heuristic title when the LLM call throws", async () => {
|
|
212
|
+
vi.mocked(buildChatModel).mockRejectedValue(new Error("provider 500"));
|
|
213
|
+
const { client, updated } = fakeClient();
|
|
214
|
+
await generateSessionSubject(EXECUTION_ID, client, OPTIONS);
|
|
215
|
+
expect(updated).toEqual([
|
|
216
|
+
{ sessionId: SESSION_ID, subject: "Explain how database indexing works for PostgreSQL" },
|
|
217
|
+
]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("falls back to the heuristic title when the LLM returns empty content", async () => {
|
|
221
|
+
mockLlmReturning("");
|
|
222
|
+
const { client, updated } = fakeClient();
|
|
223
|
+
await generateSessionSubject(EXECUTION_ID, client, OPTIONS);
|
|
224
|
+
expect(updated[0]?.subject).toBe("Explain how database indexing works for PostgreSQL");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("falls back to the heuristic in direct mode with no credential path", async () => {
|
|
228
|
+
vi.mocked(checkDirectCredentials).mockReturnValue("ANTHROPIC_API_KEY is missing");
|
|
229
|
+
const { client, updated } = fakeClient();
|
|
230
|
+
await generateSessionSubject(EXECUTION_ID, client, OPTIONS);
|
|
231
|
+
expect(vi.mocked(buildChatModel)).not.toHaveBeenCalled();
|
|
232
|
+
expect(updated[0]?.subject).toBe("Explain how database indexing works for PostgreSQL");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("does NOT run the credential pre-check in proxy mode", async () => {
|
|
236
|
+
vi.mocked(checkDirectCredentials).mockReturnValue("ANTHROPIC_API_KEY is missing");
|
|
237
|
+
const { client, updated } = fakeClient();
|
|
238
|
+
await generateSessionSubject(EXECUTION_ID, client, {
|
|
239
|
+
...OPTIONS,
|
|
240
|
+
proxyEndpoint: "https://proxy.example",
|
|
241
|
+
});
|
|
242
|
+
expect(updated[0]?.subject).toBe("PostgreSQL B-tree Indexing");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("joins array-of-parts LLM content into a single title", async () => {
|
|
246
|
+
mockLlmReturning([{ type: "text", text: "Postgres " }, { type: "text", text: "Index Tuning" }]);
|
|
247
|
+
const { client, updated } = fakeClient();
|
|
248
|
+
await generateSessionSubject(EXECUTION_ID, client, OPTIONS);
|
|
249
|
+
expect(updated[0]?.subject).toBe("Postgres Index Tuning");
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
254
|
+
// Agent resolution (direct + instance chain)
|
|
255
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
describe("resolveAgentId", () => {
|
|
258
|
+
it("prefers the execution's direct agent_id", async () => {
|
|
259
|
+
const { client } = fakeClient();
|
|
260
|
+
const id = await resolveAgentId(fakeExecution(), fakeSession(), client);
|
|
261
|
+
expect(id).toBe(AGENT_ID);
|
|
262
|
+
expect(client.getAgentInstance).not.toHaveBeenCalled();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("resolves through the session's agent-instance chain", async () => {
|
|
266
|
+
const { client } = fakeClient({ instance: fakeInstance("agt_from_instance") });
|
|
267
|
+
const id = await resolveAgentId(
|
|
268
|
+
fakeExecution({ agentId: "" }),
|
|
269
|
+
fakeSession(AUTO_CREATED_SUBJECT, INSTANCE_ID),
|
|
270
|
+
client,
|
|
271
|
+
);
|
|
272
|
+
expect(id).toBe("agt_from_instance");
|
|
273
|
+
expect(client.getAgentInstance).toHaveBeenCalledWith(INSTANCE_ID);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("returns empty when neither a direct id nor an instance exists", async () => {
|
|
277
|
+
const { client } = fakeClient();
|
|
278
|
+
const id = await resolveAgentId(fakeExecution({ agentId: "" }), fakeSession(), client);
|
|
279
|
+
expect(id).toBe("");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("returns empty when the instance row is NOT_FOUND", async () => {
|
|
283
|
+
const { client } = fakeClient({ instance: notFound() });
|
|
284
|
+
const id = await resolveAgentId(
|
|
285
|
+
fakeExecution({ agentId: "" }),
|
|
286
|
+
fakeSession(AUTO_CREATED_SUBJECT, INSTANCE_ID),
|
|
287
|
+
client,
|
|
288
|
+
);
|
|
289
|
+
expect(id).toBe("");
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
294
|
+
// Title shaping helpers (lockstep with the cloud activity)
|
|
295
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
describe("heuristicSubject", () => {
|
|
298
|
+
it("takes at most the first 7 words", () => {
|
|
299
|
+
expect(heuristicSubject("one two three four five six seven eight nine")).toBe(
|
|
300
|
+
"one two three four five six seven",
|
|
301
|
+
);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("keeps short messages whole", () => {
|
|
305
|
+
expect(heuristicSubject(" fix the build ")).toBe("fix the build");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("truncates to 50 chars with an ellipsis", () => {
|
|
309
|
+
const long = "supercalifragilistic expialidocious antidisestablishmentarianism words";
|
|
310
|
+
const subject = heuristicSubject(long);
|
|
311
|
+
expect(subject.length).toBeLessThanOrEqual(50);
|
|
312
|
+
expect(subject.endsWith("...")).toBe(true);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe("cleanSubject", () => {
|
|
317
|
+
it("strips one layer of wrapping double quotes", () => {
|
|
318
|
+
expect(cleanSubject('"Postgres Index Tuning"')).toBe("Postgres Index Tuning");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("strips one layer of wrapping single quotes", () => {
|
|
322
|
+
expect(cleanSubject("'Postgres Index Tuning'")).toBe("Postgres Index Tuning");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("caps at 50 chars with an ellipsis", () => {
|
|
326
|
+
const cleaned = cleanSubject("x".repeat(80));
|
|
327
|
+
expect(cleaned.length).toBe(50);
|
|
328
|
+
expect(cleaned.endsWith("...")).toBe(true);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("trims whitespace inside stripped quotes", () => {
|
|
332
|
+
expect(cleanSubject('" Postgres "')).toBe("Postgres");
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe("buildUserPrompt", () => {
|
|
337
|
+
it("includes the message, agent name, and purpose", () => {
|
|
338
|
+
const prompt = buildUserPrompt("fix my build", "builder", "builds things");
|
|
339
|
+
expect(prompt).toContain('User\'s first message:\n"fix my build"');
|
|
340
|
+
expect(prompt).toContain("Agent: builder");
|
|
341
|
+
expect(prompt).toContain("Agent purpose: builds things");
|
|
342
|
+
expect(prompt.endsWith("Generate the title:")).toBe(true);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("omits the purpose line when the description is empty", () => {
|
|
346
|
+
expect(buildUserPrompt("m", "a", "")).not.toContain("Agent purpose:");
|
|
347
|
+
});
|
|
348
|
+
});
|
|
@@ -753,11 +753,47 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
|
|
|
753
753
|
expect(obs.secretPaths).toEqual([]);
|
|
754
754
|
});
|
|
755
755
|
|
|
756
|
-
it("
|
|
756
|
+
it("stages a non-secret gitignored DELETE and allows it (issue #303)", async () => {
|
|
757
757
|
const h = setup({ captureMode: true, captureIgnored: true, gitignored: ["*.log"] });
|
|
758
|
-
|
|
758
|
+
writeFileSync(join(h.root, "app.log"), "DOOMED", "utf-8");
|
|
759
|
+
expect(h.decide(hookDelete("app.log")).permission).toBe("allow");
|
|
760
|
+
// Flowed, not denied — reviewed post-hoc as a DELETE entry, not a pause.
|
|
761
|
+
expect(h.ledger()).toEqual([]);
|
|
762
|
+
const obs = await h.observations();
|
|
763
|
+
expect(obs.secretPaths).toEqual([]);
|
|
764
|
+
expect(obs.captured).toHaveLength(1);
|
|
765
|
+
expect(obs.captured[0].path).toBe("app.log");
|
|
766
|
+
// The pre-delete bytes are staged — the restorable "before" side.
|
|
767
|
+
expect(Buffer.from(obs.captured[0].before!).toString("utf8")).toBe("DOOMED");
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
it("first-touch-wins across write-then-delete: the true pre-turn before survives", async () => {
|
|
771
|
+
const h = setup({ captureMode: true, captureIgnored: true, gitignored: ["*.log"] });
|
|
772
|
+
writeFileSync(join(h.root, "app.log"), "ORIGINAL", "utf-8");
|
|
773
|
+
expect(h.decide(hookWrite("app.log", "REWRITTEN")).permission).toBe("allow");
|
|
774
|
+
// Simulate the write having applied, then a delete later this turn.
|
|
775
|
+
writeFileSync(join(h.root, "app.log"), "REWRITTEN", "utf-8");
|
|
776
|
+
expect(h.decide(hookDelete("app.log")).permission).toBe("allow");
|
|
777
|
+
const obs = await h.observations();
|
|
778
|
+
expect(obs.captured).toHaveLength(1);
|
|
779
|
+
expect(Buffer.from(obs.captured[0].before!).toString("utf8")).toBe("ORIGINAL");
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
it("keeps a SECRET-LIKE gitignored delete on the deny-gate: approvable, never staged (issue #303)", async () => {
|
|
783
|
+
// Unlike a secret write (hard-blocked — its content must never surface), a
|
|
784
|
+
// delete's args expose no secret content, so a human may approve it. Its
|
|
785
|
+
// before-bytes must never enter the sidecar though: staging would both
|
|
786
|
+
// persist the secret bytes and mark the path "secret", making the boundary
|
|
787
|
+
// author a blocking DIFF_UNREVIEWABLE for a merely-gated delete.
|
|
788
|
+
const h = setup({ captureMode: true, captureIgnored: true, gitignored: [".env"] });
|
|
789
|
+
writeFileSync(join(h.root, ".env"), "API_KEY=abc", "utf-8");
|
|
790
|
+
const d = h.decide(hookDelete(".env"));
|
|
791
|
+
expect(d.permission).toBe("deny");
|
|
792
|
+
expect(d.raw).toContain("submitted to the user for approval"); // gated, not blocked
|
|
793
|
+
expect(h.ledger().map((e) => e.kind)).toEqual(["approval"]);
|
|
759
794
|
const obs = await h.observations();
|
|
760
795
|
expect(obs.captured).toEqual([]);
|
|
796
|
+
expect(obs.secretPaths).toEqual([]); // no secret marker for a gated delete
|
|
761
797
|
});
|
|
762
798
|
|
|
763
799
|
it("with captureIgnored OFF, a gitignored write stays denied and stages nothing", async () => {
|
|
@@ -768,10 +804,10 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
|
|
|
768
804
|
});
|
|
769
805
|
});
|
|
770
806
|
|
|
771
|
-
// Slice 2c: a NON-git workspace has no git snapshot, so EVERY file write
|
|
772
|
-
// CAS-staged and flowed for review
|
|
773
|
-
//
|
|
774
|
-
//
|
|
807
|
+
// Slice 2c: a NON-git workspace has no git snapshot, so EVERY file write and
|
|
808
|
+
// (issue #303) every non-secret delete is CAS-staged and flowed for review —
|
|
809
|
+
// not only gitignored ones — while shell/MCP gate as always. The workspace is
|
|
810
|
+
// deliberately NOT git-initialized.
|
|
775
811
|
describe("non-git workspace CAS capture (Slice 2c)", () => {
|
|
776
812
|
it("stages EVERY write (not just gitignored) and allows it, no denial", async () => {
|
|
777
813
|
const h = setup({ captureMode: true, captureIgnored: true, gitWorkspace: false });
|
|
@@ -806,12 +842,27 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
|
|
|
806
842
|
expect(obs.secretPaths).toEqual([".env"]);
|
|
807
843
|
});
|
|
808
844
|
|
|
809
|
-
it("
|
|
845
|
+
it("stages a DELETE with its pre-delete bytes and allows it (issue #303)", async () => {
|
|
810
846
|
const h = setup({ captureMode: true, captureIgnored: true, gitWorkspace: false });
|
|
811
|
-
|
|
812
|
-
expect(h.
|
|
847
|
+
writeFileSync(join(h.root, "notes.md"), "KEEP ME", "utf-8");
|
|
848
|
+
expect(h.decide(hookDelete("notes.md")).permission).toBe("allow");
|
|
849
|
+
expect(h.ledger()).toEqual([]);
|
|
850
|
+
const obs = await h.observations();
|
|
851
|
+
expect(obs.captured).toHaveLength(1);
|
|
852
|
+
expect(obs.captured[0].path).toBe("notes.md");
|
|
853
|
+
expect(Buffer.from(obs.captured[0].before!).toString("utf8")).toBe("KEEP ME");
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
it("keeps a secret-like DELETE gated (approvable) and stages nothing", async () => {
|
|
857
|
+
const h = setup({ captureMode: true, captureIgnored: true, gitWorkspace: false });
|
|
858
|
+
writeFileSync(join(h.root, ".env"), "API_KEY=abc", "utf-8");
|
|
859
|
+
const d = h.decide(hookDelete(".env"));
|
|
860
|
+
expect(d.permission).toBe("deny");
|
|
861
|
+
expect(d.raw).toContain("submitted to the user for approval");
|
|
862
|
+
expect(h.ledger().map((e) => e.kind)).toEqual(["approval"]);
|
|
813
863
|
const obs = await h.observations();
|
|
814
864
|
expect(obs.captured).toEqual([]);
|
|
865
|
+
expect(obs.secretPaths).toEqual([]);
|
|
815
866
|
});
|
|
816
867
|
|
|
817
868
|
it("still gates shell (never git-reversible, never CAS-captured)", async () => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import { mkdtempSync, readFileSync, existsSync, rmSync } from "node:fs";
|
|
2
|
+
import { mkdtempSync, readFileSync, existsSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { resolveSkills } from "../skill-resolver.js";
|
|
@@ -17,6 +17,7 @@ function makeSkillProto(overrides: {
|
|
|
17
17
|
description?: string;
|
|
18
18
|
skillMd?: string;
|
|
19
19
|
artifactStorageKey?: string;
|
|
20
|
+
versionHash?: string;
|
|
20
21
|
} = {}) {
|
|
21
22
|
return {
|
|
22
23
|
metadata: {
|
|
@@ -31,7 +32,7 @@ function makeSkillProto(overrides: {
|
|
|
31
32
|
},
|
|
32
33
|
status: {
|
|
33
34
|
artifactStorageKey: overrides.artifactStorageKey ?? "",
|
|
34
|
-
versionHash: "abc",
|
|
35
|
+
versionHash: overrides.versionHash ?? "abc",
|
|
35
36
|
},
|
|
36
37
|
} as any;
|
|
37
38
|
}
|
|
@@ -75,12 +76,14 @@ describe("resolveSkills — artifact extraction", () => {
|
|
|
75
76
|
...clientOverrides,
|
|
76
77
|
} as any;
|
|
77
78
|
|
|
79
|
+
// Re-run resolution for the same session — the mount-cache tests model
|
|
80
|
+
// one session's successive executions, which share the platform dir.
|
|
81
|
+
const resolveAgain = () =>
|
|
82
|
+
resolveSkills(client, refs, { sessionId, primaryWorkspaceDir: workspaceDir });
|
|
83
|
+
|
|
78
84
|
try {
|
|
79
|
-
const result = await
|
|
80
|
-
|
|
81
|
-
primaryWorkspaceDir: workspaceDir,
|
|
82
|
-
});
|
|
83
|
-
return { result, platformDir, client };
|
|
85
|
+
const result = await resolveAgain();
|
|
86
|
+
return { result, platformDir, client, resolveAgain };
|
|
84
87
|
} catch (err) {
|
|
85
88
|
// Clean up on failure
|
|
86
89
|
rmSync(platformDir, { recursive: true, force: true });
|
|
@@ -189,6 +192,159 @@ describe("resolveSkills — artifact extraction", () => {
|
|
|
189
192
|
}
|
|
190
193
|
});
|
|
191
194
|
|
|
195
|
+
it("re-resolving an unchanged version skips the artifact download (mount cache hit)", async () => {
|
|
196
|
+
const artifact = buildZip([
|
|
197
|
+
{ name: "SKILL.md", content: "# Cached" },
|
|
198
|
+
{ name: "references/guide.md", content: "guide" },
|
|
199
|
+
]);
|
|
200
|
+
const getSkillByReference = vi.fn().mockResolvedValue(
|
|
201
|
+
makeSkillProto({
|
|
202
|
+
name: "cached-skill",
|
|
203
|
+
slug: "cached-skill",
|
|
204
|
+
skillMd: "# Cached",
|
|
205
|
+
artifactStorageKey: "artifacts/cached.zip",
|
|
206
|
+
versionHash: "hash-v1",
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
const getSkillArtifact = vi.fn().mockResolvedValue({ artifact });
|
|
210
|
+
|
|
211
|
+
const { result, platformDir, client, resolveAgain } = await resolveWithMockClient(
|
|
212
|
+
[makeRef("cached-skill")],
|
|
213
|
+
{ getSkillByReference, getSkillArtifact },
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
expect(result).toHaveLength(1);
|
|
218
|
+
expect(client.getSkillArtifact).toHaveBeenCalledTimes(1);
|
|
219
|
+
|
|
220
|
+
const second = await resolveAgain();
|
|
221
|
+
expect(second).toHaveLength(1);
|
|
222
|
+
expect(second[0].name).toBe("cached-skill");
|
|
223
|
+
// Metadata was re-fetched (latest-version freshness)...
|
|
224
|
+
expect(client.getSkillByReference).toHaveBeenCalledTimes(2);
|
|
225
|
+
// ...but the artifact transfer and rewrite were skipped.
|
|
226
|
+
expect(client.getSkillArtifact).toHaveBeenCalledTimes(1);
|
|
227
|
+
|
|
228
|
+
const skillDir = join(platformDir, "skills", "cached-skill");
|
|
229
|
+
expect(readFileSync(join(skillDir, "references", "guide.md"), "utf-8")).toBe("guide");
|
|
230
|
+
} finally {
|
|
231
|
+
cleanupPlatformDir(platformDir);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("re-downloads on version change and clears stale files from the old mount", async () => {
|
|
236
|
+
const v1 = makeSkillProto({
|
|
237
|
+
name: "evolving-skill",
|
|
238
|
+
slug: "evolving-skill",
|
|
239
|
+
skillMd: "# V1",
|
|
240
|
+
artifactStorageKey: "artifacts/v1.zip",
|
|
241
|
+
versionHash: "hash-v1",
|
|
242
|
+
});
|
|
243
|
+
const v2 = makeSkillProto({
|
|
244
|
+
name: "evolving-skill",
|
|
245
|
+
slug: "evolving-skill",
|
|
246
|
+
skillMd: "# V2",
|
|
247
|
+
artifactStorageKey: "artifacts/v2.zip",
|
|
248
|
+
versionHash: "hash-v2",
|
|
249
|
+
});
|
|
250
|
+
const artifactV1 = buildZip([{ name: "references/removed-in-v2.md", content: "old" }]);
|
|
251
|
+
const artifactV2 = buildZip([{ name: "references/new-in-v2.md", content: "new" }]);
|
|
252
|
+
|
|
253
|
+
const getSkillByReference = vi.fn().mockResolvedValueOnce(v1).mockResolvedValueOnce(v2);
|
|
254
|
+
const getSkillArtifact = vi
|
|
255
|
+
.fn()
|
|
256
|
+
.mockResolvedValueOnce({ artifact: artifactV1 })
|
|
257
|
+
.mockResolvedValueOnce({ artifact: artifactV2 });
|
|
258
|
+
|
|
259
|
+
const { platformDir, client, resolveAgain } = await resolveWithMockClient(
|
|
260
|
+
[makeRef("evolving-skill")],
|
|
261
|
+
{ getSkillByReference, getSkillArtifact },
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
const skillDir = join(platformDir, "skills", "evolving-skill");
|
|
266
|
+
expect(readFileSync(join(skillDir, "references", "removed-in-v2.md"), "utf-8")).toBe("old");
|
|
267
|
+
|
|
268
|
+
await resolveAgain();
|
|
269
|
+
expect(client.getSkillArtifact).toHaveBeenCalledTimes(2);
|
|
270
|
+
expect(readFileSync(join(skillDir, "SKILL.md"), "utf-8")).toBe("# V2");
|
|
271
|
+
expect(readFileSync(join(skillDir, "references", "new-in-v2.md"), "utf-8")).toBe("new");
|
|
272
|
+
// The v1-only file must not linger in the v2 mount (the stale-file leak).
|
|
273
|
+
expect(existsSync(join(skillDir, "references", "removed-in-v2.md"))).toBe(false);
|
|
274
|
+
} finally {
|
|
275
|
+
cleanupPlatformDir(platformDir);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("does not cache a SKILL.md-only fallback — the next execution retries the download", async () => {
|
|
280
|
+
const proto = makeSkillProto({
|
|
281
|
+
name: "retry-skill",
|
|
282
|
+
slug: "retry-skill",
|
|
283
|
+
skillMd: "# Retry",
|
|
284
|
+
artifactStorageKey: "artifacts/retry.zip",
|
|
285
|
+
versionHash: "hash-v1",
|
|
286
|
+
});
|
|
287
|
+
const artifact = buildZip([{ name: "references/late.md", content: "finally" }]);
|
|
288
|
+
const getSkillByReference = vi.fn().mockResolvedValue(proto);
|
|
289
|
+
const getSkillArtifact = vi
|
|
290
|
+
.fn()
|
|
291
|
+
.mockRejectedValueOnce(new Error("Network timeout"))
|
|
292
|
+
.mockResolvedValueOnce({ artifact });
|
|
293
|
+
|
|
294
|
+
const { result, platformDir, client, resolveAgain } = await resolveWithMockClient(
|
|
295
|
+
[makeRef("retry-skill")],
|
|
296
|
+
{ getSkillByReference, getSkillArtifact },
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
// First pass degraded to SKILL.md only.
|
|
301
|
+
expect(result).toHaveLength(1);
|
|
302
|
+
const skillDir = join(platformDir, "skills", "retry-skill");
|
|
303
|
+
expect(existsSync(join(skillDir, "references"))).toBe(false);
|
|
304
|
+
|
|
305
|
+
// Second pass retries and completes the mount.
|
|
306
|
+
await resolveAgain();
|
|
307
|
+
expect(client.getSkillArtifact).toHaveBeenCalledTimes(2);
|
|
308
|
+
expect(readFileSync(join(skillDir, "references", "late.md"), "utf-8")).toBe("finally");
|
|
309
|
+
|
|
310
|
+
// Third pass is a cache hit.
|
|
311
|
+
await resolveAgain();
|
|
312
|
+
expect(client.getSkillArtifact).toHaveBeenCalledTimes(2);
|
|
313
|
+
} finally {
|
|
314
|
+
cleanupPlatformDir(platformDir);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("treats a corrupted mount marker as stale and remounts", async () => {
|
|
319
|
+
const artifact = buildZip([{ name: "references/guide.md", content: "guide" }]);
|
|
320
|
+
const getSkillByReference = vi.fn().mockResolvedValue(
|
|
321
|
+
makeSkillProto({
|
|
322
|
+
name: "tampered-skill",
|
|
323
|
+
slug: "tampered-skill",
|
|
324
|
+
skillMd: "# Tampered",
|
|
325
|
+
artifactStorageKey: "artifacts/tampered.zip",
|
|
326
|
+
versionHash: "hash-v1",
|
|
327
|
+
}),
|
|
328
|
+
);
|
|
329
|
+
const getSkillArtifact = vi.fn().mockResolvedValue({ artifact });
|
|
330
|
+
|
|
331
|
+
const { platformDir, client, resolveAgain } = await resolveWithMockClient(
|
|
332
|
+
[makeRef("tampered-skill")],
|
|
333
|
+
{ getSkillByReference, getSkillArtifact },
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
const skillDir = join(platformDir, "skills", "tampered-skill");
|
|
338
|
+
writeFileSync(join(skillDir, ".stigmer-mount.json"), "not json {");
|
|
339
|
+
|
|
340
|
+
await resolveAgain();
|
|
341
|
+
expect(client.getSkillArtifact).toHaveBeenCalledTimes(2);
|
|
342
|
+
expect(readFileSync(join(skillDir, "references", "guide.md"), "utf-8")).toBe("guide");
|
|
343
|
+
} finally {
|
|
344
|
+
cleanupPlatformDir(platformDir);
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
192
348
|
it("SKILL.md from spec takes precedence over ZIP copy", async () => {
|
|
193
349
|
const specContent = "# Authoritative SKILL.md from spec";
|
|
194
350
|
const zipContent = "# Stale SKILL.md from ZIP";
|
|
@@ -112,10 +112,11 @@ export function captureBaselineToLedger(opts: {
|
|
|
112
112
|
* bytes withheld from durable storage.
|
|
113
113
|
*
|
|
114
114
|
* `deniedTokens` are the identities the hook gated this turn (shell/MCP, or a
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
115
|
+
* secret-like delete — non-secret CAS deletes flow since issue #303). A streamed
|
|
116
|
+
* file-edit row whose identity is in that set is left for the deny-gate
|
|
117
|
+
* reconcile path — it did NOT flow. A flowed gitignored write or delete is NOT
|
|
118
|
+
* in that set (the hook allowed it), so it is stamped like any other flowed
|
|
119
|
+
* edit and its captured delta surfaces as a CAS entry in the set.
|
|
119
120
|
*
|
|
120
121
|
* `hitlDir`/`storage` are omitted only by callers with no artifact storage
|
|
121
122
|
* (captureIgnored off); the CAS half is then skipped and this is a git-only
|