| author | |
| committer | |
| log | 135dc156e3f600a79a415b06e4c234677aa318d0 |
| tree | c46387d27192b98db381ebb3480f4b20a6255d89 |
| parent | d79f975d0b39805c443e97accfcd4aa60786f13f |
| signature | Signed by PGP key B5690EEEBB952194 |
3 files changed, 226 insertions(+), 1 deletions(-)
apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts+35-1| ... | ... | @@ -24,6 +24,7 @@ import { makeCodexSessionRuntime } from "./CodexSessionRuntime.ts"; |
| 24 | 24 | |
| 25 | 25 | const ROOT = wireFixture.rootThreadId; |
| 26 | 26 | const [CHILD_A, CHILD_B] = wireFixture.childThreadIds as [string, string]; |
| 27 | const MEMORY = "memory-consolidation-thread"; | |
| 27 | 28 | |
| 28 | 29 | /** |
| 29 | 30 | * The captured sequence, extended with the shapes the live capture didn't |
| ... | ... | @@ -174,15 +175,44 @@ describe("CodexSessionRuntime collab integration", () => { |
| 174 | 175 | const turnStartedB = byIndex.find((entry) => isTurnStarted(entry, CHILD_B)); |
| 175 | 176 | const registrationA = byIndex.find((entry) => isRegistration(entry, CHILD_A)); |
| 176 | 177 | const registrationB = byIndex.find((entry) => isRegistration(entry, CHILD_B)); |
| 178 | const rootThreadStarted = byIndex.find((entry) => entry.method === "thread/started"); | |
| 177 | 179 | assert.isDefined(turnStartedA); |
| 178 | 180 | assert.isDefined(turnStartedB); |
| 179 | 181 | assert.isDefined(registrationA); |
| 180 | 182 | assert.isDefined(registrationB); |
| 183 | assert.isDefined(rootThreadStarted); | |
| 184 | const memoryThreadStarted = { | |
| 185 | ...rootThreadStarted, | |
| 186 | params: { | |
| 187 | thread: { | |
| 188 | ...rootThreadStarted.params.thread, | |
| 189 | id: MEMORY, | |
| 190 | sessionId: MEMORY, | |
| 191 | source: "unknown", | |
| 192 | threadSource: "memory_consolidation", | |
| 193 | }, | |
| 194 | }, | |
| 195 | }; | |
| 196 | const memoryTurnStarted = { | |
| 197 | ...turnStartedA, | |
| 198 | params: { | |
| 199 | ...turnStartedA.params, | |
| 200 | threadId: MEMORY, | |
| 201 | turn: { ...turnStartedA.params.turn, id: "memory-consolidation-turn" }, | |
| 202 | }, | |
| 203 | }; | |
| 181 | 204 | const script = { |
| 182 | 205 | rootThreadId: ROOT, |
| 183 | 206 | holdTurnOpen: true, |
| 184 | 207 | hangInterruptFor: CHILD_A, |
| 185 | notifications: [turnStartedA, registrationA, registrationB, turnStartedB], | |
| 208 | notifications: [ | |
| 209 | turnStartedA, | |
| 210 | registrationA, | |
| 211 | memoryThreadStarted, | |
| 212 | memoryTurnStarted, | |
| 213 | registrationB, | |
| 214 | turnStartedB, | |
| 215 | ], | |
| 186 | 216 | }; |
| 187 | 217 | // @effect-diagnostics-next-line preferSchemaOverJson:off |
| 188 | 218 | NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); |
| ... | ... | @@ -240,6 +270,10 @@ describe("CodexSessionRuntime collab integration", () => { |
| 240 | 270 | "pre-registration child A must still receive the interrupt RPC", |
| 241 | 271 | ); |
| 242 | 272 | assert.isTrue(interruptedThreads.has(CHILD_B), "registered child B must be interrupted"); |
| 273 | assert.isTrue( | |
| 274 | interruptedThreads.has(MEMORY), | |
| 275 | "memory consolidation must be interrupted without appearing in chat", | |
| 276 | ); | |
| 243 | 277 | assert.isTrue(interruptedThreads.has(ROOT), "parent turn must be interrupted last"); |
| 244 | 278 | |
| 245 | 279 | yield* runtime.close; |
apps/server/src/provider/Layers/CodexSessionRuntime.test.ts+140| ... | ... | @@ -7,6 +7,7 @@ import { describe } from "vite-plus/test"; |
| 7 | 7 | import { DEFAULT_MODEL, ThreadId } from "@t3tools/contracts"; |
| 8 | 8 | import * as CodexErrors from "effect-codex-app-server/errors"; |
| 9 | 9 | import * as CodexRpc from "effect-codex-app-server/rpc"; |
| 10 | import * as EffectCodexSchema from "effect-codex-app-server/schema"; | |
| 10 | 11 | |
| 11 | 12 | import { |
| 12 | 13 | buildCodexDeveloperInstructions, |
| ... | ... | @@ -18,6 +19,7 @@ import { |
| 18 | 19 | buildTurnStartParams, |
| 19 | 20 | hasConfiguredMcpServer, |
| 20 | 21 | isRecoverableThreadResumeError, |
| 22 | makeMemoryConsolidationNotificationFilter, | |
| 21 | 23 | openCodexThread, |
| 22 | 24 | } from "./CodexSessionRuntime.ts"; |
| 23 | 25 | const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); |
| ... | ... | @@ -318,6 +320,144 @@ describe("hasConfiguredMcpServer", () => { |
| 318 | 320 | }); |
| 319 | 321 | }); |
| 320 | 322 | |
| 323 | function makeThreadStartedNotification( | |
| 324 | threadId: string, | |
| 325 | source: EffectCodexSchema.V2ThreadStartedNotification["thread"]["source"], | |
| 326 | threadSource?: string, | |
| 327 | ) { | |
| 328 | return { | |
| 329 | method: "thread/started" as const, | |
| 330 | params: { | |
| 331 | thread: { | |
| 332 | cliVersion: "0.0.0", | |
| 333 | createdAt: 0, | |
| 334 | cwd: "/tmp/project", | |
| 335 | ephemeral: true, | |
| 336 | id: threadId, | |
| 337 | modelProvider: "openai", | |
| 338 | preview: "", | |
| 339 | sessionId: threadId, | |
| 340 | source, | |
| 341 | status: { type: "idle" as const }, | |
| 342 | ...(threadSource ? { threadSource } : {}), | |
| 343 | turns: [], | |
| 344 | updatedAt: 0, | |
| 345 | }, | |
| 346 | }, | |
| 347 | }; | |
| 348 | } | |
| 349 | ||
| 350 | describe("makeMemoryConsolidationNotificationFilter", () => { | |
| 351 | it("suppresses memory consolidation without hiding other Codex subagents", () => { | |
| 352 | const shouldSuppress = makeMemoryConsolidationNotificationFilter(); | |
| 353 | ||
| 354 | NodeAssert.equal( | |
| 355 | shouldSuppress( | |
| 356 | makeThreadStartedNotification("memory-thread", "unknown", "memory_consolidation"), | |
| 357 | ), | |
| 358 | true, | |
| 359 | ); | |
| 360 | NodeAssert.equal( | |
| 361 | shouldSuppress({ | |
| 362 | method: "item/agentMessage/delta", | |
| 363 | params: { | |
| 364 | delta: "internal memory update", | |
| 365 | itemId: "memory-message", | |
| 366 | threadId: "memory-thread", | |
| 367 | turnId: "memory-turn", | |
| 368 | }, | |
| 369 | }), | |
| 370 | true, | |
| 371 | ); | |
| 372 | NodeAssert.equal( | |
| 373 | shouldSuppress({ | |
| 374 | method: "serverRequest/resolved", | |
| 375 | params: { | |
| 376 | requestId: "memory-approval", | |
| 377 | threadId: "memory-thread", | |
| 378 | }, | |
| 379 | }), | |
| 380 | false, | |
| 381 | ); | |
| 382 | NodeAssert.equal( | |
| 383 | shouldSuppress({ | |
| 384 | method: "warning", | |
| 385 | params: { | |
| 386 | message: "internal warning", | |
| 387 | threadId: "memory-thread", | |
| 388 | }, | |
| 389 | }), | |
| 390 | true, | |
| 391 | ); | |
| 392 | NodeAssert.equal( | |
| 393 | shouldSuppress({ | |
| 394 | method: "item/agentMessage/delta", | |
| 395 | params: { | |
| 396 | delta: "normal reply", | |
| 397 | itemId: "root-message", | |
| 398 | threadId: "root-thread", | |
| 399 | turnId: "root-turn", | |
| 400 | }, | |
| 401 | }), | |
| 402 | false, | |
| 403 | ); | |
| 404 | ||
| 405 | NodeAssert.equal( | |
| 406 | shouldSuppress( | |
| 407 | makeThreadStartedNotification("legacy-memory-thread", { | |
| 408 | subAgent: "memory_consolidation", | |
| 409 | }), | |
| 410 | ), | |
| 411 | true, | |
| 412 | ); | |
| 413 | ||
| 414 | for (const source of [ | |
| 415 | { subAgent: "review" as const }, | |
| 416 | { subAgent: "compact" as const }, | |
| 417 | { | |
| 418 | subAgent: { | |
| 419 | thread_spawn: { | |
| 420 | depth: 1, | |
| 421 | parent_thread_id: "root-thread", | |
| 422 | }, | |
| 423 | }, | |
| 424 | }, | |
| 425 | ]) { | |
| 426 | NodeAssert.equal( | |
| 427 | shouldSuppress(makeThreadStartedNotification("visible-subagent", source)), | |
| 428 | false, | |
| 429 | ); | |
| 430 | } | |
| 431 | }); | |
| 432 | ||
| 433 | it("forgets memory consolidation threads after they close", () => { | |
| 434 | const shouldSuppress = makeMemoryConsolidationNotificationFilter(); | |
| 435 | shouldSuppress( | |
| 436 | makeThreadStartedNotification("memory-thread", "unknown", "memory_consolidation"), | |
| 437 | ); | |
| 438 | ||
| 439 | NodeAssert.equal( | |
| 440 | shouldSuppress({ | |
| 441 | method: "thread/closed", | |
| 442 | params: { threadId: "memory-thread" }, | |
| 443 | }), | |
| 444 | true, | |
| 445 | ); | |
| 446 | NodeAssert.equal( | |
| 447 | shouldSuppress({ | |
| 448 | method: "item/agentMessage/delta", | |
| 449 | params: { | |
| 450 | delta: "later message", | |
| 451 | itemId: "later-message", | |
| 452 | threadId: "memory-thread", | |
| 453 | turnId: "later-turn", | |
| 454 | }, | |
| 455 | }), | |
| 456 | false, | |
| 457 | ); | |
| 458 | }); | |
| 459 | }); | |
| 460 | ||
| 321 | 461 | describe("codexSessionAppServerArgs", () => { |
| 322 | 462 | it("keeps the app-server subcommand when explicit args are provided", () => { |
| 323 | 463 | NodeAssert.deepStrictEqual(codexSessionAppServerArgs(["-c", "model=gpt-5"], undefined), [ |
apps/server/src/provider/Layers/CodexSessionRuntime.ts+51| ... | ... | @@ -541,6 +541,49 @@ function readNotificationThreadId(notification: CodexServerNotification): string |
| 541 | 541 | } |
| 542 | 542 | } |
| 543 | 543 | |
| 544 | export function makeMemoryConsolidationNotificationFilter(): ( | |
| 545 | notification: CodexServerNotification, | |
| 546 | ) => boolean { | |
| 547 | const threadIds = new Set<string>(); | |
| 548 | ||
| 549 | return (notification) => { | |
| 550 | if (notification.method === "thread/started") { | |
| 551 | const thread = notification.params.thread; | |
| 552 | const source = thread.source; | |
| 553 | if ( | |
| 554 | thread.threadSource === "memory_consolidation" || | |
| 555 | (typeof source === "object" && | |
| 556 | source !== null && | |
| 557 | "subAgent" in source && | |
| 558 | source.subAgent === "memory_consolidation") | |
| 559 | ) { | |
| 560 | threadIds.add(thread.id); | |
| 561 | return true; | |
| 562 | } | |
| 563 | } | |
| 564 | ||
| 565 | const params = notification.params; | |
| 566 | const threadId = | |
| 567 | notification.method === "thread/started" | |
| 568 | ? notification.params.thread.id | |
| 569 | : "threadId" in params && typeof params.threadId === "string" | |
| 570 | ? params.threadId | |
| 571 | : undefined; | |
| 572 | if (!threadId || !threadIds.has(threadId)) { | |
| 573 | return false; | |
| 574 | } | |
| 575 | ||
| 576 | if (notification.method === "serverRequest/resolved") { | |
| 577 | return false; | |
| 578 | } | |
| 579 | ||
| 580 | if (notification.method === "thread/closed") { | |
| 581 | threadIds.delete(threadId); | |
| 582 | } | |
| 583 | return true; | |
| 584 | }; | |
| 585 | } | |
| 586 | ||
| 544 | 587 | function readRouteFields(notification: CodexServerNotification): { |
| 545 | 588 | readonly turnId: TurnId | undefined; |
| 546 | 589 | readonly itemId: ProviderItemId | undefined; |
| ... | ... | @@ -857,6 +900,7 @@ export const makeCodexSessionRuntime = ( |
| 857 | 900 | const collabChildAgentsRef = yield* Ref.make(new Map<string, CollabChildAgentState>()); |
| 858 | 901 | /** Child provider-thread id → its currently running provider turn id. */ |
| 859 | 902 | const collabChildLiveTurnsRef = yield* Ref.make(new Map<string, string>()); |
| 903 | const suppressMemoryConsolidationNotification = makeMemoryConsolidationNotificationFilter(); | |
| 860 | 904 | const closedRef = yield* Ref.make(false); |
| 861 | 905 | |
| 862 | 906 | // `~` is not shell-expanded when env vars are set via |
| ... | ... | @@ -1249,6 +1293,9 @@ export const makeCodexSessionRuntime = ( |
| 1249 | 1293 | |
| 1250 | 1294 | const handleRawNotification = (notification: CodexServerNotification) => |
| 1251 | 1295 | Effect.gen(function* () { |
| 1296 | const isMemoryConsolidationNotification = | |
| 1297 | suppressMemoryConsolidationNotification(notification); | |
| 1298 | ||
| 1252 | 1299 | const payload = notification.params; |
| 1253 | 1300 | const route = readRouteFields(notification); |
| 1254 | 1301 | const collabReceiverTurns = yield* Ref.get(collabReceiverTurnsRef); |
| ... | ... | @@ -1326,6 +1373,10 @@ export const makeCodexSessionRuntime = ( |
| 1326 | 1373 | return; |
| 1327 | 1374 | } |
| 1328 | 1375 | |
| 1376 | if (isMemoryConsolidationNotification) { | |
| 1377 | return; | |
| 1378 | } | |
| 1379 | ||
| 1329 | 1380 | let requestId: ApprovalRequestId | undefined; |
| 1330 | 1381 | let requestKind: ProviderRequestKind | undefined; |
| 1331 | 1382 | let turnId = childParentTurnId ?? route.turnId; |