| ... | ... | @@ -3,7 +3,9 @@ import * as NodeV8 from "node:v8"; |
| 3 | 3 | import { |
| 4 | 4 | CommandId, |
| 5 | 5 | EventId, |
| 6 | NodeId, |
| 6 | 7 | ProjectId, |
| 8 | ProviderDriverKind, |
| 7 | 9 | ProviderInstanceId, |
| 8 | 10 | ThreadId, |
| 9 | 11 | TurnItemId, |
| ... | ... | @@ -103,6 +105,199 @@ layer("OrchestrationEventStore", (it) => { |
| 103 | 105 | ), |
| 104 | 106 | ); |
| 105 | 107 | |
| 108 | it.effect("stores an unchanged subagent prompt once and replays it on every update", () => |
| 109 | Effect.gen(function* () { |
| 110 | const store = yield* OrchestrationEventStore.OrchestrationEventStore; |
| 111 | const sql = yield* SqlClient.SqlClient; |
| 112 | const afterSequence = yield* store.latestApplicationSequence; |
| 113 | const now = yield* DateTime.now; |
| 114 | const threadId = ThreadId.make("thread:subagent-prompt"); |
| 115 | const id = NodeId.make("node:subagent-prompt"); |
| 116 | const driver = ProviderDriverKind.make("claudeAgent"); |
| 117 | const providerInstanceId = ProviderInstanceId.make("claudeAgent"); |
| 118 | const prompt = "Audit the repository. ".repeat(200); |
| 119 | const subagent = (index: number, progress: string, nextPrompt = prompt) => |
| 120 | ({ |
| 121 | id: EventId.make(`event:subagent-prompt:subagent:${index}`), |
| 122 | type: "subagent.updated", |
| 123 | threadId, |
| 124 | occurredAt: now, |
| 125 | payload: { |
| 126 | id, |
| 127 | threadId, |
| 128 | runId: null, |
| 129 | parentNodeId: NodeId.make("node:subagent-prompt-parent"), |
| 130 | origin: "provider_native", |
| 131 | createdBy: "agent", |
| 132 | driver, |
| 133 | providerInstanceId, |
| 134 | providerThreadId: null, |
| 135 | childThreadId: null, |
| 136 | nativeTaskRef: { driver, nativeId: "subagent-prompt", strength: "strong" }, |
| 137 | prompt: nextPrompt, |
| 138 | progress, |
| 139 | title: "Audit", |
| 140 | model: null, |
| 141 | status: "running", |
| 142 | result: null, |
| 143 | startedAt: now, |
| 144 | completedAt: null, |
| 145 | updatedAt: now, |
| 146 | }, |
| 147 | }) satisfies OrchestrationV2DomainEvent; |
| 148 | const item = (index: number, progress: string, nextPrompt = prompt) => |
| 149 | ({ |
| 150 | id: EventId.make(`event:subagent-prompt:item:${index}`), |
| 151 | type: "turn-item.updated", |
| 152 | threadId, |
| 153 | occurredAt: now, |
| 154 | payload: { |
| 155 | id: TurnItemId.make("item:subagent-prompt"), |
| 156 | type: "subagent", |
| 157 | threadId, |
| 158 | runId: null, |
| 159 | nodeId: null, |
| 160 | providerThreadId: null, |
| 161 | providerTurnId: null, |
| 162 | nativeItemRef: null, |
| 163 | parentItemId: null, |
| 164 | ordinal: 1, |
| 165 | status: "running", |
| 166 | title: "Audit", |
| 167 | subagentId: id, |
| 168 | origin: "provider_native", |
| 169 | driver, |
| 170 | providerInstanceId, |
| 171 | childThreadId: null, |
| 172 | prompt: nextPrompt, |
| 173 | progress, |
| 174 | result: null, |
| 175 | startedAt: now, |
| 176 | completedAt: null, |
| 177 | updatedAt: now, |
| 178 | }, |
| 179 | }) satisfies OrchestrationV2DomainEvent; |
| 180 | const events = [ |
| 181 | subagent(0, "Reading files"), |
| 182 | item(0, "Reading files"), |
| 183 | subagent(1, "Running tests"), |
| 184 | item(1, "Running tests"), |
| 185 | subagent(2, "Writing report"), |
| 186 | item(2, "Writing report"), |
| 187 | subagent(3, "Retrying", "Audit again."), |
| 188 | item(3, "Retrying", "Audit again."), |
| 189 | ]; |
| 190 | yield* store.appendAgentEvents({ events }); |
| 191 | |
| 192 | const stored = yield* sql<{ readonly payload_json: string }>` |
| 193 | SELECT payload_json FROM orchestration_events |
| 194 | WHERE stream_id = ${threadId} ORDER BY sequence ASC |
| 195 | `; |
| 196 | assert.deepEqual( |
| 197 | stored.map((row) => row.payload_json.includes(prompt)), |
| 198 | [true, true, false, false, false, false, false, false], |
| 199 | ); |
| 200 | const replayed = yield* store |
| 201 | .readAgentEvents({ threadId, afterSequence }) |
| 202 | .pipe(Stream.runCollect); |
| 203 | assert.deepEqual( |
| 204 | replayed.map((entry) => entry.event), |
| 205 | events, |
| 206 | ); |
| 207 | // Replay budgets measure the payloads replay decodes, not the slimmer stored rows. |
| 208 | const stats = yield* store.getAgentReplayStats({ |
| 209 | threadId, |
| 210 | afterSequence, |
| 211 | throughSequence: yield* store.latestApplicationSequence, |
| 212 | maxEvents: events.length, |
| 213 | }); |
| 214 | assert.isAbove(stats.rawPayloadBytes, prompt.length * 6); |
| 215 | const applicationStats = yield* store.getReplayStats({ |
| 216 | afterSequence, |
| 217 | throughSequence: yield* store.latestApplicationSequence, |
| 218 | }); |
| 219 | assert.isAbove(applicationStats.rawPayloadBytes, prompt.length * 6); |
| 220 | }), |
| 221 | ); |
| 222 | |
| 223 | it.effect("stores the full subagent prompt when its hint row was rolled back", () => |
| 224 | Effect.gen(function* () { |
| 225 | const store = yield* OrchestrationEventStore.OrchestrationEventStore; |
| 226 | const sql = yield* SqlClient.SqlClient; |
| 227 | const afterSequence = yield* store.latestApplicationSequence; |
| 228 | const now = yield* DateTime.now; |
| 229 | const threadId = ThreadId.make("thread:subagent-rollback"); |
| 230 | const event = { |
| 231 | id: EventId.make("event:subagent-rollback"), |
| 232 | type: "subagent.updated", |
| 233 | threadId, |
| 234 | occurredAt: now, |
| 235 | payload: { |
| 236 | id: NodeId.make("node:subagent-rollback"), |
| 237 | threadId, |
| 238 | runId: null, |
| 239 | parentNodeId: NodeId.make("node:subagent-rollback-parent"), |
| 240 | origin: "provider_native", |
| 241 | createdBy: "agent", |
| 242 | driver: ProviderDriverKind.make("claudeAgent"), |
| 243 | providerInstanceId: ProviderInstanceId.make("claudeAgent"), |
| 244 | providerThreadId: null, |
| 245 | childThreadId: null, |
| 246 | nativeTaskRef: null, |
| 247 | prompt: "Audit the repository. ".repeat(200), |
| 248 | progress: "Reading files", |
| 249 | title: "Audit", |
| 250 | model: null, |
| 251 | status: "running", |
| 252 | result: null, |
| 253 | startedAt: now, |
| 254 | completedAt: null, |
| 255 | updatedAt: now, |
| 256 | }, |
| 257 | } satisfies OrchestrationV2DomainEvent; |
| 258 | const rolledBack = yield* sql |
| 259 | .withTransaction( |
| 260 | store |
| 261 | .appendAgentEvents({ events: [event] }) |
| 262 | .pipe(Effect.andThen(Effect.fail("rollback"))), |
| 263 | ) |
| 264 | .pipe(Effect.flip); |
| 265 | assert.equal(rolledBack, "rollback"); |
| 266 | const rowsAfterRollback = yield* sql` |
| 267 | SELECT sequence FROM orchestration_events WHERE stream_id = ${threadId} |
| 268 | `; |
| 269 | assert.deepEqual(rowsAfterRollback, []); |
| 270 | |
| 271 | const events = [event, { ...event, id: EventId.make("event:subagent-after-rollback") }]; |
| 272 | const appended = yield* store.appendAgentEvents({ events }); |
| 273 | assert.deepEqual( |
| 274 | appended.map((entry) => entry.event), |
| 275 | events, |
| 276 | ); |
| 277 | const stored = yield* sql<{ |
| 278 | readonly prompt: string | null; |
| 279 | readonly prompt_sequence: number | null; |
| 280 | }>` |
| 281 | SELECT json_extract(payload_json, '$.prompt') AS prompt, |
| 282 | json_extract(payload_json, '$.promptSequence') AS prompt_sequence |
| 283 | FROM orchestration_events WHERE stream_id = ${threadId} ORDER BY sequence |
| 284 | `; |
| 285 | assert.deepEqual(stored, [ |
| 286 | { prompt: event.payload.prompt, prompt_sequence: null }, |
| 287 | { prompt: "", prompt_sequence: appended[0]!.sequence }, |
| 288 | ]); |
| 289 | const replayed = yield* store.readAgentEvents({ threadId }).pipe(Stream.runCollect); |
| 290 | assert.deepEqual(replayed, appended); |
| 291 | const applicationEvents = yield* store |
| 292 | .readApplicationEvents({ |
| 293 | afterSequence, |
| 294 | throughSequence: appended[1]!.sequence, |
| 295 | }) |
| 296 | .pipe(Stream.runCollect); |
| 297 | assert.deepEqual(applicationEvents, [...appended]); |
| 298 | }), |
| 299 | ); |
| 300 | |
| 106 | 301 | it.effect("stores json columns as strings and replays CLI-origin events", () => |
| 107 | 302 | Effect.gen(function* () { |
| 108 | 303 | const eventStore = yield* OrchestrationEventStore.OrchestrationEventStore; |