| 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,6 +24,7 @@ import { makeCodexSessionRuntime } from "./CodexSessionRuntime.ts"; |
| 24 | 24 | ||
| 25 | const ROOT = wireFixture.rootThreadId; | 25 | const ROOT = wireFixture.rootThreadId; |
| 26 | const [CHILD_A, CHILD_B] = wireFixture.childThreadIds as [string, string]; | 26 | const [CHILD_A, CHILD_B] = wireFixture.childThreadIds as [string, string]; |
| 27 | const MEMORY = "memory-consolidation-thread"; | ||
| 27 | 28 | ||
| 28 | /** | 29 | /** |
| 29 | * The captured sequence, extended with the shapes the live capture didn't | 30 | * The captured sequence, extended with the shapes the live capture didn't |
| ... | @@ -174,15 +175,44 @@ describe("CodexSessionRuntime collab integration", () => { | ... | @@ -174,15 +175,44 @@ describe("CodexSessionRuntime collab integration", () => { |
| 174 | const turnStartedB = byIndex.find((entry) => isTurnStarted(entry, CHILD_B)); | 175 | const turnStartedB = byIndex.find((entry) => isTurnStarted(entry, CHILD_B)); |
| 175 | const registrationA = byIndex.find((entry) => isRegistration(entry, CHILD_A)); | 176 | const registrationA = byIndex.find((entry) => isRegistration(entry, CHILD_A)); |
| 176 | const registrationB = byIndex.find((entry) => isRegistration(entry, CHILD_B)); | 177 | const registrationB = byIndex.find((entry) => isRegistration(entry, CHILD_B)); |
| 178 | const rootThreadStarted = byIndex.find((entry) => entry.method === "thread/started"); | ||
| 177 | assert.isDefined(turnStartedA); | 179 | assert.isDefined(turnStartedA); |
| 178 | assert.isDefined(turnStartedB); | 180 | assert.isDefined(turnStartedB); |
| 179 | assert.isDefined(registrationA); | 181 | assert.isDefined(registrationA); |
| 180 | assert.isDefined(registrationB); | 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 | const script = { | 204 | const script = { |
| 182 | rootThreadId: ROOT, | 205 | rootThreadId: ROOT, |
| 183 | holdTurnOpen: true, | 206 | holdTurnOpen: true, |
| 184 | hangInterruptFor: CHILD_A, | 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 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 217 | // @effect-diagnostics-next-line preferSchemaOverJson:off |
| 188 | NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); | 218 | NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); |
| ... | @@ -240,6 +270,10 @@ describe("CodexSessionRuntime collab integration", () => { | ... | @@ -240,6 +270,10 @@ describe("CodexSessionRuntime collab integration", () => { |
| 240 | "pre-registration child A must still receive the interrupt RPC", | 270 | "pre-registration child A must still receive the interrupt RPC", |
| 241 | ); | 271 | ); |
| 242 | assert.isTrue(interruptedThreads.has(CHILD_B), "registered child B must be interrupted"); | 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 | assert.isTrue(interruptedThreads.has(ROOT), "parent turn must be interrupted last"); | 277 | assert.isTrue(interruptedThreads.has(ROOT), "parent turn must be interrupted last"); |
| 244 | 278 | ||
| 245 | yield* runtime.close; | 279 | yield* runtime.close; |
apps/server/src/provider/Layers/CodexSessionRuntime.test.ts+140| ... | @@ -7,6 +7,7 @@ import { describe } from "vite-plus/test"; | ... | @@ -7,6 +7,7 @@ import { describe } from "vite-plus/test"; |
| 7 | import { DEFAULT_MODEL, ThreadId } from "@t3tools/contracts"; | 7 | import { DEFAULT_MODEL, ThreadId } from "@t3tools/contracts"; |
| 8 | import * as CodexErrors from "effect-codex-app-server/errors"; | 8 | import * as CodexErrors from "effect-codex-app-server/errors"; |
| 9 | import * as CodexRpc from "effect-codex-app-server/rpc"; | 9 | import * as CodexRpc from "effect-codex-app-server/rpc"; |
| 10 | import * as EffectCodexSchema from "effect-codex-app-server/schema"; | ||
| 10 | 11 | ||
| 11 | import { | 12 | import { |
| 12 | buildCodexDeveloperInstructions, | 13 | buildCodexDeveloperInstructions, |
| ... | @@ -18,6 +19,7 @@ import { | ... | @@ -18,6 +19,7 @@ import { |
| 18 | buildTurnStartParams, | 19 | buildTurnStartParams, |
| 19 | hasConfiguredMcpServer, | 20 | hasConfiguredMcpServer, |
| 20 | isRecoverableThreadResumeError, | 21 | isRecoverableThreadResumeError, |
| 22 | makeMemoryConsolidationNotificationFilter, | ||
| 21 | openCodexThread, | 23 | openCodexThread, |
| 22 | } from "./CodexSessionRuntime.ts"; | 24 | } from "./CodexSessionRuntime.ts"; |
| 23 | const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); | 25 | const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); |
| ... | @@ -318,6 +320,144 @@ describe("hasConfiguredMcpServer", () => { | ... | @@ -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 | describe("codexSessionAppServerArgs", () => { | 461 | describe("codexSessionAppServerArgs", () => { |
| 322 | it("keeps the app-server subcommand when explicit args are provided", () => { | 462 | it("keeps the app-server subcommand when explicit args are provided", () => { |
| 323 | NodeAssert.deepStrictEqual(codexSessionAppServerArgs(["-c", "model=gpt-5"], undefined), [ | 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,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 | function readRouteFields(notification: CodexServerNotification): { | 587 | function readRouteFields(notification: CodexServerNotification): { |
| 545 | readonly turnId: TurnId | undefined; | 588 | readonly turnId: TurnId | undefined; |
| 546 | readonly itemId: ProviderItemId | undefined; | 589 | readonly itemId: ProviderItemId | undefined; |
| ... | @@ -857,6 +900,7 @@ export const makeCodexSessionRuntime = ( | ... | @@ -857,6 +900,7 @@ export const makeCodexSessionRuntime = ( |
| 857 | const collabChildAgentsRef = yield* Ref.make(new Map<string, CollabChildAgentState>()); | 900 | const collabChildAgentsRef = yield* Ref.make(new Map<string, CollabChildAgentState>()); |
| 858 | /** Child provider-thread id → its currently running provider turn id. */ | 901 | /** Child provider-thread id → its currently running provider turn id. */ |
| 859 | const collabChildLiveTurnsRef = yield* Ref.make(new Map<string, string>()); | 902 | const collabChildLiveTurnsRef = yield* Ref.make(new Map<string, string>()); |
| 903 | const suppressMemoryConsolidationNotification = makeMemoryConsolidationNotificationFilter(); | ||
| 860 | const closedRef = yield* Ref.make(false); | 904 | const closedRef = yield* Ref.make(false); |
| 861 | 905 | ||
| 862 | // `~` is not shell-expanded when env vars are set via | 906 | // `~` is not shell-expanded when env vars are set via |
| ... | @@ -1249,6 +1293,9 @@ export const makeCodexSessionRuntime = ( | ... | @@ -1249,6 +1293,9 @@ export const makeCodexSessionRuntime = ( |
| 1249 | 1293 | ||
| 1250 | const handleRawNotification = (notification: CodexServerNotification) => | 1294 | const handleRawNotification = (notification: CodexServerNotification) => |
| 1251 | Effect.gen(function* () { | 1295 | Effect.gen(function* () { |
| 1296 | const isMemoryConsolidationNotification = | ||
| 1297 | suppressMemoryConsolidationNotification(notification); | ||
| 1298 | |||
| 1252 | const payload = notification.params; | 1299 | const payload = notification.params; |
| 1253 | const route = readRouteFields(notification); | 1300 | const route = readRouteFields(notification); |
| 1254 | const collabReceiverTurns = yield* Ref.get(collabReceiverTurnsRef); | 1301 | const collabReceiverTurns = yield* Ref.get(collabReceiverTurnsRef); |
| ... | @@ -1326,6 +1373,10 @@ export const makeCodexSessionRuntime = ( | ... | @@ -1326,6 +1373,10 @@ export const makeCodexSessionRuntime = ( |
| 1326 | return; | 1373 | return; |
| 1327 | } | 1374 | } |
| 1328 | 1375 | ||
| 1376 | if (isMemoryConsolidationNotification) { | ||
| 1377 | return; | ||
| 1378 | } | ||
| 1379 | |||
| 1329 | let requestId: ApprovalRequestId | undefined; | 1380 | let requestId: ApprovalRequestId | undefined; |
| 1330 | let requestKind: ProviderRequestKind | undefined; | 1381 | let requestKind: ProviderRequestKind | undefined; |
| 1331 | let turnId = childParentTurnId ?? route.turnId; | 1382 | let turnId = childParentTurnId ?? route.turnId; |