@stigmer/runner 3.12.0 → 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.
Files changed (97) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/capture-flow.d.ts +5 -4
  3. package/dist/activities/execute-cursor/capture-flow.js +5 -4
  4. package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
  5. package/dist/activities/execute-cursor/cas-observations.d.ts +10 -4
  6. package/dist/activities/execute-cursor/cas-observations.js +10 -4
  7. package/dist/activities/execute-cursor/cas-observations.js.map +1 -1
  8. package/dist/activities/execute-cursor/hook-script.js +39 -13
  9. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  10. package/dist/activities/execute-cursor/skill-resolver.d.ts +8 -0
  11. package/dist/activities/execute-cursor/skill-resolver.js +77 -26
  12. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  13. package/dist/activities/execute-deep-agent/attachment-injector.d.ts +23 -8
  14. package/dist/activities/execute-deep-agent/attachment-injector.js +104 -105
  15. package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
  16. package/dist/activities/execute-deep-agent/cas-capture-backend.d.ts +3 -1
  17. package/dist/activities/execute-deep-agent/cas-capture-backend.js +3 -1
  18. package/dist/activities/execute-deep-agent/cas-capture-backend.js.map +1 -1
  19. package/dist/activities/execute-deep-agent/prompt-builder.js +11 -1
  20. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  21. package/dist/activities/execute-deep-agent/setup.js +15 -3
  22. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  23. package/dist/activities/execute-deep-agent/subagent-wiring.js +16 -9
  24. package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
  25. package/dist/activities/generate-session-subject.d.ts +80 -0
  26. package/dist/activities/generate-session-subject.js +283 -0
  27. package/dist/activities/generate-session-subject.js.map +1 -0
  28. package/dist/client/stigmer-client.d.ts +8 -0
  29. package/dist/client/stigmer-client.js +10 -0
  30. package/dist/client/stigmer-client.js.map +1 -1
  31. package/dist/middleware/approval-gate.d.ts +25 -10
  32. package/dist/middleware/approval-gate.js +33 -14
  33. package/dist/middleware/approval-gate.js.map +1 -1
  34. package/dist/middleware/otel-spans.d.ts +2 -1
  35. package/dist/middleware/otel-spans.js +2 -1
  36. package/dist/middleware/otel-spans.js.map +1 -1
  37. package/dist/middleware/path-normalization.d.ts +15 -2
  38. package/dist/middleware/path-normalization.js +39 -5
  39. package/dist/middleware/path-normalization.js.map +1 -1
  40. package/dist/runner-manager.js +3 -1
  41. package/dist/runner-manager.js.map +1 -1
  42. package/dist/runner.js +3 -1
  43. package/dist/runner.js.map +1 -1
  44. package/dist/shared/attachment-vision.js +9 -0
  45. package/dist/shared/attachment-vision.js.map +1 -1
  46. package/dist/shared/mcp-enabled-tools.d.ts +6 -2
  47. package/dist/shared/mcp-enabled-tools.js +6 -2
  48. package/dist/shared/mcp-enabled-tools.js.map +1 -1
  49. package/dist/shared/mcp-manager.js +8 -0
  50. package/dist/shared/mcp-manager.js.map +1 -1
  51. package/dist/shared/plan-mode-permissions.d.ts +46 -10
  52. package/dist/shared/plan-mode-permissions.js +56 -12
  53. package/dist/shared/plan-mode-permissions.js.map +1 -1
  54. package/dist/shared/zip-extract.d.ts +24 -6
  55. package/dist/shared/zip-extract.js +31 -90
  56. package/dist/shared/zip-extract.js.map +1 -1
  57. package/dist/shared/zip-structure.d.ts +61 -0
  58. package/dist/shared/zip-structure.js +128 -0
  59. package/dist/shared/zip-structure.js.map +1 -0
  60. package/package.json +2 -2
  61. package/src/__test-utils__/zip-fixtures.ts +206 -0
  62. package/src/activities/__tests__/generate-session-subject.test.ts +348 -0
  63. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +60 -9
  64. package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +166 -49
  65. package/src/activities/execute-cursor/capture-flow.ts +5 -4
  66. package/src/activities/execute-cursor/cas-observations.ts +10 -4
  67. package/src/activities/execute-cursor/hook-script.ts +39 -13
  68. package/src/activities/execute-cursor/skill-resolver.ts +98 -31
  69. package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +165 -126
  70. package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +246 -37
  71. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +7 -2
  72. package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +35 -4
  73. package/src/activities/execute-deep-agent/attachment-injector.ts +146 -142
  74. package/src/activities/execute-deep-agent/cas-capture-backend.ts +3 -1
  75. package/src/activities/execute-deep-agent/prompt-builder.ts +11 -1
  76. package/src/activities/execute-deep-agent/setup.ts +15 -3
  77. package/src/activities/execute-deep-agent/subagent-wiring.ts +16 -9
  78. package/src/activities/generate-session-subject.ts +370 -0
  79. package/src/client/stigmer-client.ts +11 -0
  80. package/src/middleware/__tests__/approval-gate.test.ts +130 -1
  81. package/src/middleware/__tests__/path-normalization.test.ts +29 -3
  82. package/src/middleware/approval-gate.ts +58 -24
  83. package/src/middleware/otel-spans.ts +2 -1
  84. package/src/middleware/path-normalization.ts +42 -5
  85. package/src/runner-manager.ts +3 -0
  86. package/src/runner.ts +3 -0
  87. package/src/shared/__tests__/attachment-vision.test.ts +4 -0
  88. package/src/shared/__tests__/mcp-manager.test.ts +7 -2
  89. package/src/shared/__tests__/plan-mode-permissions.test.ts +58 -0
  90. package/src/shared/__tests__/zip-extract.test.ts +106 -89
  91. package/src/shared/attachment-vision.ts +9 -0
  92. package/src/shared/filereview/__tests__/capture.test.ts +48 -0
  93. package/src/shared/mcp-enabled-tools.ts +6 -2
  94. package/src/shared/mcp-manager.ts +8 -0
  95. package/src/shared/plan-mode-permissions.ts +59 -12
  96. package/src/shared/zip-extract.ts +35 -117
  97. package/src/shared/zip-structure.ts +181 -0
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Shared ZIP fixture builder for runner unit tests.
3
+ *
4
+ * Emits complete, real-shaped archives — local file headers (optionally
5
+ * streaming-style, the Go stdlib writer's default), payloads, central
6
+ * directory, and EOCD — because that is the only shape that can reach the
7
+ * runner: both editions' skill push gates validate artifacts with
8
+ * central-directory-based readers (see zip-extract.ts's module doc and
9
+ * design record 017). Earlier fixtures emitted local headers only, a
10
+ * shape no real ZIP writer produces, and were coupled to the old parser's
11
+ * front-to-back walk.
12
+ *
13
+ * Mirrors the conformance suite's `zipFilesStreaming()`
14
+ * (test/conformance/src/support/skills.ts) so unit fixtures and
15
+ * cross-edition fixtures share one shape.
16
+ *
17
+ * Lives in src/__test-utils__/ so `tsc --noEmit` covers it while
18
+ * tsconfig.build.json keeps it out of dist/.
19
+ */
20
+
21
+ import { deflateRawSync } from "node:zlib";
22
+
23
+ export interface ZipFixtureFile {
24
+ name: string;
25
+ /** Text content is UTF-8 encoded; pass bytes directly for binary payloads. */
26
+ content: string | Uint8Array;
27
+ /** Compression method for the entry. Defaults to stored. */
28
+ method?: "stored" | "deflated";
29
+ /**
30
+ * Emit the entry the way Go's archive/zip does by default: general-purpose
31
+ * flag bit 3 set, zeroed sizes in the local header, and a trailing data
32
+ * descriptor (with the conventional signature) carrying the real values.
33
+ */
34
+ streaming?: boolean;
35
+ /**
36
+ * Lie about the entry's uncompressed size everywhere the writer would
37
+ * declare it (local header, data descriptor, central directory). Models a
38
+ * crafted archive whose declarations disagree with its actual payload —
39
+ * the input class the attachment injector's declared-size enforcement
40
+ * exists to reject (issue #567).
41
+ */
42
+ declaredUncompressedSize?: number;
43
+ }
44
+
45
+ export interface ZipFixtureOptions {
46
+ /** Trailing archive comment appended after the EOCD record. */
47
+ comment?: string;
48
+ /**
49
+ * Drop the central directory and EOCD, leaving only local headers and
50
+ * payloads. No real ZIP writer produces this shape — it models a download
51
+ * truncated before the archive's index.
52
+ */
53
+ omitCentralDirectory?: boolean;
54
+ }
55
+
56
+ /** Build a ZIP archive from path + content pairs. */
57
+ export function buildZip(files: ZipFixtureFile[], options?: ZipFixtureOptions): Uint8Array {
58
+ const out = new ByteWriter();
59
+
60
+ interface WrittenEntry {
61
+ nameBytes: Uint8Array;
62
+ payload: Uint8Array;
63
+ declaredUncompressed: number;
64
+ crc: number;
65
+ flags: number;
66
+ method: number;
67
+ localHeaderOffset: number;
68
+ }
69
+ const written: WrittenEntry[] = [];
70
+
71
+ for (const file of files) {
72
+ const contentBytes =
73
+ typeof file.content === "string" ? new TextEncoder().encode(file.content) : file.content;
74
+ const deflated = file.method === "deflated";
75
+ const payload = deflated ? new Uint8Array(deflateRawSync(contentBytes)) : contentBytes;
76
+ const entry: WrittenEntry = {
77
+ nameBytes: new TextEncoder().encode(file.name),
78
+ payload,
79
+ declaredUncompressed: file.declaredUncompressedSize ?? contentBytes.length,
80
+ crc: crc32(contentBytes),
81
+ flags: file.streaming ? 0x0008 : 0,
82
+ method: deflated ? 8 : 0,
83
+ localHeaderOffset: out.length,
84
+ };
85
+ written.push(entry);
86
+
87
+ out.u32(0x04034b50); // local file header signature
88
+ out.u16(20); // version needed to extract
89
+ out.u16(entry.flags);
90
+ out.u16(entry.method);
91
+ out.u16(0); // mod time
92
+ out.u16(0); // mod date
93
+ out.u32(file.streaming ? 0 : entry.crc);
94
+ out.u32(file.streaming ? 0 : payload.length);
95
+ out.u32(file.streaming ? 0 : entry.declaredUncompressed);
96
+ out.u16(entry.nameBytes.length);
97
+ out.u16(0); // extra field length
98
+ out.raw(entry.nameBytes);
99
+ out.raw(payload);
100
+
101
+ if (file.streaming) {
102
+ out.u32(0x08074b50); // data descriptor signature (Go writes it)
103
+ out.u32(entry.crc);
104
+ out.u32(payload.length);
105
+ out.u32(entry.declaredUncompressed);
106
+ }
107
+ }
108
+
109
+ if (options?.omitCentralDirectory) {
110
+ return out.toUint8Array();
111
+ }
112
+
113
+ const centralDirectoryOffset = out.length;
114
+ for (const entry of written) {
115
+ out.u32(0x02014b50); // central directory record signature
116
+ out.u16(20); // version made by
117
+ out.u16(20); // version needed to extract
118
+ out.u16(entry.flags);
119
+ out.u16(entry.method);
120
+ out.u16(0); // mod time
121
+ out.u16(0); // mod date
122
+ out.u32(entry.crc);
123
+ out.u32(entry.payload.length);
124
+ out.u32(entry.declaredUncompressed);
125
+ out.u16(entry.nameBytes.length);
126
+ out.u16(0); // extra field length
127
+ out.u16(0); // comment length
128
+ out.u16(0); // disk number start
129
+ out.u16(0); // internal attributes
130
+ out.u32(0); // external attributes
131
+ out.u32(entry.localHeaderOffset);
132
+ out.raw(entry.nameBytes);
133
+ }
134
+ const centralDirectorySize = out.length - centralDirectoryOffset;
135
+
136
+ const commentBytes = new TextEncoder().encode(options?.comment ?? "");
137
+ out.u32(0x06054b50); // EOCD signature
138
+ out.u16(0); // disk number
139
+ out.u16(0); // disk with central directory
140
+ out.u16(written.length); // entries on this disk
141
+ out.u16(written.length); // total entries
142
+ out.u32(centralDirectorySize);
143
+ out.u32(centralDirectoryOffset);
144
+ out.u16(commentBytes.length);
145
+ out.raw(commentBytes);
146
+
147
+ return out.toUint8Array();
148
+ }
149
+
150
+ // Chunked assembly instead of a number[]-per-byte accumulator: fixtures at
151
+ // the injector's 100 MB zip-bomb limit are built from payload-sized chunks,
152
+ // which a per-byte spread-push cannot survive (argument-count overflow).
153
+ class ByteWriter {
154
+ private readonly chunks: Uint8Array[] = [];
155
+ private size = 0;
156
+
157
+ get length(): number {
158
+ return this.size;
159
+ }
160
+
161
+ u16(v: number): void {
162
+ this.raw(new Uint8Array([v & 0xff, (v >>> 8) & 0xff]));
163
+ }
164
+
165
+ u32(v: number): void {
166
+ this.raw(new Uint8Array([v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff]));
167
+ }
168
+
169
+ raw(b: Uint8Array): void {
170
+ this.chunks.push(b);
171
+ this.size += b.length;
172
+ }
173
+
174
+ toUint8Array(): Uint8Array {
175
+ const result = new Uint8Array(this.size);
176
+ let offset = 0;
177
+ for (const chunk of this.chunks) {
178
+ result.set(chunk, offset);
179
+ offset += chunk.length;
180
+ }
181
+ return result;
182
+ }
183
+ }
184
+
185
+ // Standard CRC-32 (IEEE 802.3, the ZIP checksum), table-driven so fixtures
186
+ // at the injector's 100 MB limit stay cheap to build. Semantically identical
187
+ // to the conformance suite's inline bit-loop helper.
188
+ const CRC_TABLE = (() => {
189
+ const table = new Uint32Array(256);
190
+ for (let n = 0; n < 256; n++) {
191
+ let c = n;
192
+ for (let bit = 0; bit < 8; bit++) {
193
+ c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
194
+ }
195
+ table[n] = c;
196
+ }
197
+ return table;
198
+ })();
199
+
200
+ function crc32(data: Uint8Array): number {
201
+ let crc = 0xffffffff;
202
+ for (let i = 0; i < data.length; i++) {
203
+ crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ data[i]!) & 0xff]!;
204
+ }
205
+ return (crc ^ 0xffffffff) >>> 0;
206
+ }
@@ -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("still gates a gitignored DELETE (no CAS capture path, parity with deep-agent)", async () => {
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
- expect(h.decide(hookDelete("app.log")).permission).toBe("deny");
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 is
772
- // CAS-staged and flowed for review (not only gitignored ones), a delete stays
773
- // gated (no CAS delete-capture path, parity with the deep-agent), and shell/MCP
774
- // gate as always. The workspace is deliberately NOT git-initialized.
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("still gates a DELETE (deny-gate, parity with deep-agent) and stages nothing", async () => {
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
- expect(h.decide(hookDelete("notes.md")).permission).toBe("deny");
812
- expect(h.ledger()).toHaveLength(1);
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 () => {