| ... | ... | @@ -20,6 +20,7 @@ import { |
| 20 | 20 | ProviderItemId, |
| 21 | 21 | ThreadId, |
| 22 | 22 | TurnId, |
| 23 | ProviderSendTurnInput, |
| 23 | 24 | } from "@t3tools/contracts"; |
| 24 | 25 | import { Effect, FileSystem, Layer, Queue, Schema, ServiceMap, Stream } from "effect"; |
| 25 | 26 | |
| ... | ... | @@ -1327,38 +1328,40 @@ function mapToRuntimeEvents( |
| 1327 | 1328 | return []; |
| 1328 | 1329 | } |
| 1329 | 1330 | |
| 1330 | | const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => |
| 1331 | | Effect.gen(function* () { |
| 1332 | | const fileSystem = yield* FileSystem.FileSystem; |
| 1333 | | const serverConfig = yield* Effect.service(ServerConfig); |
| 1334 | | const nativeEventLogger = |
| 1335 | | options?.nativeEventLogger ?? |
| 1336 | | (options?.nativeEventLogPath !== undefined |
| 1337 | | ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { |
| 1338 | | stream: "native", |
| 1339 | | }) |
| 1340 | | : undefined); |
| 1341 | | |
| 1342 | | const manager = yield* Effect.acquireRelease( |
| 1343 | | Effect.gen(function* () { |
| 1344 | | if (options?.manager) { |
| 1345 | | return options.manager; |
| 1346 | | } |
| 1347 | | const services = yield* Effect.services<never>(); |
| 1348 | | return options?.makeManager?.(services) ?? new CodexAppServerManager(services); |
| 1349 | | }), |
| 1350 | | (manager) => |
| 1351 | | Effect.sync(() => { |
| 1352 | | try { |
| 1353 | | manager.stopAll(); |
| 1354 | | } catch { |
| 1355 | | // Finalizers should never fail and block shutdown. |
| 1356 | | } |
| 1357 | | }), |
| 1358 | | ); |
| 1359 | | const serverSettingsService = yield* ServerSettingsService; |
| 1331 | const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( |
| 1332 | options?: CodexAdapterLiveOptions, |
| 1333 | ) { |
| 1334 | const fileSystem = yield* FileSystem.FileSystem; |
| 1335 | const serverConfig = yield* Effect.service(ServerConfig); |
| 1336 | const nativeEventLogger = |
| 1337 | options?.nativeEventLogger ?? |
| 1338 | (options?.nativeEventLogPath !== undefined |
| 1339 | ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { |
| 1340 | stream: "native", |
| 1341 | }) |
| 1342 | : undefined); |
| 1360 | 1343 | |
| 1361 | | const startSession: CodexAdapterShape["startSession"] = Effect.fn(function* (input) { |
| 1344 | const acquireManager = Effect.fn("acquireManager")(function* () { |
| 1345 | if (options?.manager) { |
| 1346 | return options.manager; |
| 1347 | } |
| 1348 | const services = yield* Effect.services<never>(); |
| 1349 | return options?.makeManager?.(services) ?? new CodexAppServerManager(services); |
| 1350 | }); |
| 1351 | |
| 1352 | const manager = yield* Effect.acquireRelease(acquireManager(), (manager) => |
| 1353 | Effect.sync(() => { |
| 1354 | try { |
| 1355 | manager.stopAll(); |
| 1356 | } catch { |
| 1357 | // Finalizers should never fail and block shutdown. |
| 1358 | } |
| 1359 | }), |
| 1360 | ); |
| 1361 | const serverSettingsService = yield* ServerSettingsService; |
| 1362 | |
| 1363 | const startSession: CodexAdapterShape["startSession"] = Effect.fn("startSession")( |
| 1364 | function* (input) { |
| 1362 | 1365 | if (input.provider !== undefined && input.provider !== PROVIDER) { |
| 1363 | 1366 | return yield* new ProviderAdapterValidationError({ |
| 1364 | 1367 | provider: PROVIDER, |
| ... | ... | @@ -1407,211 +1410,212 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => |
| 1407 | 1410 | cause, |
| 1408 | 1411 | }), |
| 1409 | 1412 | }); |
| 1413 | }, |
| 1414 | ); |
| 1415 | |
| 1416 | const resolveAttachment = Effect.fn("resolveAttachment")(function* ( |
| 1417 | input: ProviderSendTurnInput, |
| 1418 | attachment: NonNullable<ProviderSendTurnInput["attachments"]>[number], |
| 1419 | ) { |
| 1420 | const attachmentPath = resolveAttachmentPath({ |
| 1421 | attachmentsDir: serverConfig.attachmentsDir, |
| 1422 | attachment, |
| 1410 | 1423 | }); |
| 1424 | if (!attachmentPath) { |
| 1425 | return yield* toRequestError( |
| 1426 | input.threadId, |
| 1427 | "turn/start", |
| 1428 | new Error(`Invalid attachment id '${attachment.id}'.`), |
| 1429 | ); |
| 1430 | } |
| 1431 | const bytes = yield* fileSystem.readFile(attachmentPath).pipe( |
| 1432 | Effect.mapError( |
| 1433 | (cause) => |
| 1434 | new ProviderAdapterRequestError({ |
| 1435 | provider: PROVIDER, |
| 1436 | method: "turn/start", |
| 1437 | detail: toMessage(cause, "Failed to read attachment file."), |
| 1438 | cause, |
| 1439 | }), |
| 1440 | ), |
| 1441 | ); |
| 1442 | return { |
| 1443 | type: "image" as const, |
| 1444 | url: `data:${attachment.mimeType};base64,${Buffer.from(bytes).toString("base64")}`, |
| 1445 | }; |
| 1446 | }); |
| 1411 | 1447 | |
| 1412 | | const sendTurn: CodexAdapterShape["sendTurn"] = (input) => |
| 1413 | | Effect.gen(function* () { |
| 1414 | | const codexAttachments = yield* Effect.forEach( |
| 1415 | | input.attachments ?? [], |
| 1416 | | (attachment) => |
| 1417 | | Effect.gen(function* () { |
| 1418 | | const attachmentPath = resolveAttachmentPath({ |
| 1419 | | attachmentsDir: serverConfig.attachmentsDir, |
| 1420 | | attachment, |
| 1421 | | }); |
| 1422 | | if (!attachmentPath) { |
| 1423 | | return yield* toRequestError( |
| 1424 | | input.threadId, |
| 1425 | | "turn/start", |
| 1426 | | new Error(`Invalid attachment id '${attachment.id}'.`), |
| 1427 | | ); |
| 1428 | | } |
| 1429 | | const bytes = yield* fileSystem.readFile(attachmentPath).pipe( |
| 1430 | | Effect.mapError( |
| 1431 | | (cause) => |
| 1432 | | new ProviderAdapterRequestError({ |
| 1433 | | provider: PROVIDER, |
| 1434 | | method: "turn/start", |
| 1435 | | detail: toMessage(cause, "Failed to read attachment file."), |
| 1436 | | cause, |
| 1437 | | }), |
| 1438 | | ), |
| 1439 | | ); |
| 1440 | | return { |
| 1441 | | type: "image" as const, |
| 1442 | | url: `data:${attachment.mimeType};base64,${Buffer.from(bytes).toString("base64")}`, |
| 1443 | | }; |
| 1444 | | }), |
| 1445 | | { concurrency: 1 }, |
| 1446 | | ); |
| 1448 | const sendTurn: CodexAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { |
| 1449 | const codexAttachments = yield* Effect.forEach( |
| 1450 | input.attachments ?? [], |
| 1451 | (attachment) => resolveAttachment(input, attachment), |
| 1452 | { concurrency: 1 }, |
| 1453 | ); |
| 1447 | 1454 | |
| 1448 | | return yield* Effect.tryPromise({ |
| 1449 | | try: () => { |
| 1450 | | const managerInput = { |
| 1451 | | threadId: input.threadId, |
| 1452 | | ...(input.input !== undefined ? { input: input.input } : {}), |
| 1453 | | ...(input.modelSelection?.provider === "codex" |
| 1454 | | ? { model: input.modelSelection.model } |
| 1455 | | : {}), |
| 1456 | | ...(input.modelSelection?.provider === "codex" && |
| 1457 | | input.modelSelection.options?.reasoningEffort !== undefined |
| 1458 | | ? { effort: input.modelSelection.options.reasoningEffort } |
| 1459 | | : {}), |
| 1460 | | ...(input.modelSelection?.provider === "codex" && |
| 1461 | | input.modelSelection.options?.fastMode |
| 1462 | | ? { serviceTier: "fast" } |
| 1463 | | : {}), |
| 1464 | | ...(input.interactionMode !== undefined |
| 1465 | | ? { interactionMode: input.interactionMode } |
| 1466 | | : {}), |
| 1467 | | ...(codexAttachments.length > 0 ? { attachments: codexAttachments } : {}), |
| 1468 | | }; |
| 1469 | | return manager.sendTurn(managerInput); |
| 1470 | | }, |
| 1471 | | catch: (cause) => toRequestError(input.threadId, "turn/start", cause), |
| 1472 | | }).pipe( |
| 1473 | | Effect.map((result) => ({ |
| 1474 | | ...result, |
| 1475 | | threadId: input.threadId, |
| 1476 | | })), |
| 1477 | | ); |
| 1478 | | }); |
| 1455 | return yield* Effect.tryPromise({ |
| 1456 | try: () => { |
| 1457 | const managerInput = { |
| 1458 | threadId: input.threadId, |
| 1459 | ...(input.input !== undefined ? { input: input.input } : {}), |
| 1460 | ...(input.modelSelection?.provider === "codex" |
| 1461 | ? { model: input.modelSelection.model } |
| 1462 | : {}), |
| 1463 | ...(input.modelSelection?.provider === "codex" && |
| 1464 | input.modelSelection.options?.reasoningEffort !== undefined |
| 1465 | ? { effort: input.modelSelection.options.reasoningEffort } |
| 1466 | : {}), |
| 1467 | ...(input.modelSelection?.provider === "codex" && input.modelSelection.options?.fastMode |
| 1468 | ? { serviceTier: "fast" } |
| 1469 | : {}), |
| 1470 | ...(input.interactionMode !== undefined |
| 1471 | ? { interactionMode: input.interactionMode } |
| 1472 | : {}), |
| 1473 | ...(codexAttachments.length > 0 ? { attachments: codexAttachments } : {}), |
| 1474 | }; |
| 1475 | return manager.sendTurn(managerInput); |
| 1476 | }, |
| 1477 | catch: (cause) => toRequestError(input.threadId, "turn/start", cause), |
| 1478 | }).pipe( |
| 1479 | Effect.map((result) => ({ |
| 1480 | ...result, |
| 1481 | threadId: input.threadId, |
| 1482 | })), |
| 1483 | ); |
| 1484 | }); |
| 1479 | 1485 | |
| 1480 | | const interruptTurn: CodexAdapterShape["interruptTurn"] = (threadId, turnId) => |
| 1481 | | Effect.tryPromise({ |
| 1482 | | try: () => manager.interruptTurn(threadId, turnId), |
| 1483 | | catch: (cause) => toRequestError(threadId, "turn/interrupt", cause), |
| 1484 | | }); |
| 1486 | const interruptTurn: CodexAdapterShape["interruptTurn"] = (threadId, turnId) => |
| 1487 | Effect.tryPromise({ |
| 1488 | try: () => manager.interruptTurn(threadId, turnId), |
| 1489 | catch: (cause) => toRequestError(threadId, "turn/interrupt", cause), |
| 1490 | }); |
| 1485 | 1491 | |
| 1486 | | const readThread: CodexAdapterShape["readThread"] = (threadId) => |
| 1487 | | Effect.tryPromise({ |
| 1488 | | try: () => manager.readThread(threadId), |
| 1489 | | catch: (cause) => toRequestError(threadId, "thread/read", cause), |
| 1490 | | }).pipe( |
| 1491 | | Effect.map((snapshot) => ({ |
| 1492 | | threadId, |
| 1493 | | turns: snapshot.turns, |
| 1494 | | })), |
| 1492 | const readThread: CodexAdapterShape["readThread"] = (threadId) => |
| 1493 | Effect.tryPromise({ |
| 1494 | try: () => manager.readThread(threadId), |
| 1495 | catch: (cause) => toRequestError(threadId, "thread/read", cause), |
| 1496 | }).pipe( |
| 1497 | Effect.map((snapshot) => ({ |
| 1498 | threadId, |
| 1499 | turns: snapshot.turns, |
| 1500 | })), |
| 1501 | ); |
| 1502 | |
| 1503 | const rollbackThread: CodexAdapterShape["rollbackThread"] = (threadId, numTurns) => { |
| 1504 | if (!Number.isInteger(numTurns) || numTurns < 1) { |
| 1505 | return Effect.fail( |
| 1506 | new ProviderAdapterValidationError({ |
| 1507 | provider: PROVIDER, |
| 1508 | operation: "rollbackThread", |
| 1509 | issue: "numTurns must be an integer >= 1.", |
| 1510 | }), |
| 1495 | 1511 | ); |
| 1512 | } |
| 1496 | 1513 | |
| 1497 | | const rollbackThread: CodexAdapterShape["rollbackThread"] = (threadId, numTurns) => { |
| 1498 | | if (!Number.isInteger(numTurns) || numTurns < 1) { |
| 1499 | | return Effect.fail( |
| 1500 | | new ProviderAdapterValidationError({ |
| 1501 | | provider: PROVIDER, |
| 1502 | | operation: "rollbackThread", |
| 1503 | | issue: "numTurns must be an integer >= 1.", |
| 1504 | | }), |
| 1505 | | ); |
| 1506 | | } |
| 1514 | return Effect.tryPromise({ |
| 1515 | try: () => manager.rollbackThread(threadId, numTurns), |
| 1516 | catch: (cause) => toRequestError(threadId, "thread/rollback", cause), |
| 1517 | }).pipe( |
| 1518 | Effect.map((snapshot) => ({ |
| 1519 | threadId, |
| 1520 | turns: snapshot.turns, |
| 1521 | })), |
| 1522 | ); |
| 1523 | }; |
| 1507 | 1524 | |
| 1508 | | return Effect.tryPromise({ |
| 1509 | | try: () => manager.rollbackThread(threadId, numTurns), |
| 1510 | | catch: (cause) => toRequestError(threadId, "thread/rollback", cause), |
| 1511 | | }).pipe( |
| 1512 | | Effect.map((snapshot) => ({ |
| 1513 | | threadId, |
| 1514 | | turns: snapshot.turns, |
| 1515 | | })), |
| 1516 | | ); |
| 1517 | | }; |
| 1525 | const respondToRequest: CodexAdapterShape["respondToRequest"] = (threadId, requestId, decision) => |
| 1526 | Effect.tryPromise({ |
| 1527 | try: () => manager.respondToRequest(threadId, requestId, decision), |
| 1528 | catch: (cause) => toRequestError(threadId, "item/requestApproval/decision", cause), |
| 1529 | }); |
| 1518 | 1530 | |
| 1519 | | const respondToRequest: CodexAdapterShape["respondToRequest"] = ( |
| 1520 | | threadId, |
| 1521 | | requestId, |
| 1522 | | decision, |
| 1523 | | ) => |
| 1524 | | Effect.tryPromise({ |
| 1525 | | try: () => manager.respondToRequest(threadId, requestId, decision), |
| 1526 | | catch: (cause) => toRequestError(threadId, "item/requestApproval/decision", cause), |
| 1527 | | }); |
| 1531 | const respondToUserInput: CodexAdapterShape["respondToUserInput"] = ( |
| 1532 | threadId, |
| 1533 | requestId, |
| 1534 | answers, |
| 1535 | ) => |
| 1536 | Effect.tryPromise({ |
| 1537 | try: () => manager.respondToUserInput(threadId, requestId, answers), |
| 1538 | catch: (cause) => toRequestError(threadId, "item/tool/requestUserInput", cause), |
| 1539 | }); |
| 1528 | 1540 | |
| 1529 | | const respondToUserInput: CodexAdapterShape["respondToUserInput"] = ( |
| 1530 | | threadId, |
| 1531 | | requestId, |
| 1532 | | answers, |
| 1533 | | ) => |
| 1534 | | Effect.tryPromise({ |
| 1535 | | try: () => manager.respondToUserInput(threadId, requestId, answers), |
| 1536 | | catch: (cause) => toRequestError(threadId, "item/tool/requestUserInput", cause), |
| 1537 | | }); |
| 1541 | const stopSession: CodexAdapterShape["stopSession"] = (threadId) => |
| 1542 | Effect.sync(() => { |
| 1543 | manager.stopSession(threadId); |
| 1544 | }); |
| 1538 | 1545 | |
| 1539 | | const stopSession: CodexAdapterShape["stopSession"] = (threadId) => |
| 1540 | | Effect.sync(() => { |
| 1541 | | manager.stopSession(threadId); |
| 1542 | | }); |
| 1546 | const listSessions: CodexAdapterShape["listSessions"] = () => |
| 1547 | Effect.sync(() => manager.listSessions()); |
| 1543 | 1548 | |
| 1544 | | const listSessions: CodexAdapterShape["listSessions"] = () => |
| 1545 | | Effect.sync(() => manager.listSessions()); |
| 1549 | const hasSession: CodexAdapterShape["hasSession"] = (threadId) => |
| 1550 | Effect.sync(() => manager.hasSession(threadId)); |
| 1546 | 1551 | |
| 1547 | | const hasSession: CodexAdapterShape["hasSession"] = (threadId) => |
| 1548 | | Effect.sync(() => manager.hasSession(threadId)); |
| 1552 | const stopAll: CodexAdapterShape["stopAll"] = () => |
| 1553 | Effect.sync(() => { |
| 1554 | manager.stopAll(); |
| 1555 | }); |
| 1549 | 1556 | |
| 1550 | | const stopAll: CodexAdapterShape["stopAll"] = () => |
| 1551 | | Effect.sync(() => { |
| 1552 | | manager.stopAll(); |
| 1553 | | }); |
| 1557 | const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>(); |
| 1554 | 1558 | |
| 1555 | | const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>(); |
| 1556 | | |
| 1557 | | yield* Effect.acquireRelease( |
| 1558 | | Effect.gen(function* () { |
| 1559 | | const writeNativeEvent = (event: ProviderEvent) => |
| 1560 | | Effect.gen(function* () { |
| 1561 | | if (!nativeEventLogger) { |
| 1562 | | return; |
| 1563 | | } |
| 1564 | | yield* nativeEventLogger.write(event, event.threadId); |
| 1565 | | }); |
| 1566 | | |
| 1567 | | const services = yield* Effect.services<never>(); |
| 1568 | | const listener = (event: ProviderEvent) => |
| 1569 | | Effect.gen(function* () { |
| 1570 | | yield* writeNativeEvent(event); |
| 1571 | | const runtimeEvents = mapToRuntimeEvents(event, event.threadId); |
| 1572 | | if (runtimeEvents.length === 0) { |
| 1573 | | yield* Effect.logDebug("ignoring unhandled Codex provider event", { |
| 1574 | | method: event.method, |
| 1575 | | threadId: event.threadId, |
| 1576 | | turnId: event.turnId, |
| 1577 | | itemId: event.itemId, |
| 1578 | | }); |
| 1579 | | return; |
| 1580 | | } |
| 1581 | | yield* Queue.offerAll(runtimeEventQueue, runtimeEvents); |
| 1582 | | }).pipe(Effect.runPromiseWith(services)); |
| 1583 | | manager.on("event", listener); |
| 1584 | | return listener; |
| 1585 | | }), |
| 1586 | | (listener) => |
| 1587 | | Effect.gen(function* () { |
| 1588 | | yield* Effect.sync(() => { |
| 1589 | | manager.off("event", listener); |
| 1590 | | }); |
| 1591 | | yield* Queue.shutdown(runtimeEventQueue); |
| 1592 | | }), |
| 1593 | | ); |
| 1559 | const writeNativeEvent = Effect.fn("writeNativeEvent")(function* (event: ProviderEvent) { |
| 1560 | if (!nativeEventLogger) { |
| 1561 | return; |
| 1562 | } |
| 1563 | yield* nativeEventLogger.write(event, event.threadId); |
| 1564 | }); |
| 1594 | 1565 | |
| 1595 | | return { |
| 1596 | | provider: PROVIDER, |
| 1597 | | capabilities: { |
| 1598 | | sessionModelSwitch: "in-session", |
| 1599 | | }, |
| 1600 | | startSession, |
| 1601 | | sendTurn, |
| 1602 | | interruptTurn, |
| 1603 | | readThread, |
| 1604 | | rollbackThread, |
| 1605 | | respondToRequest, |
| 1606 | | respondToUserInput, |
| 1607 | | stopSession, |
| 1608 | | listSessions, |
| 1609 | | hasSession, |
| 1610 | | stopAll, |
| 1611 | | streamEvents: Stream.fromQueue(runtimeEventQueue), |
| 1612 | | } satisfies CodexAdapterShape; |
| 1566 | const registerListener = Effect.fn("registerListener")(function* () { |
| 1567 | const services = yield* Effect.services<never>(); |
| 1568 | const listenerEffect = Effect.fn("listener")(function* (event: ProviderEvent) { |
| 1569 | yield* writeNativeEvent(event); |
| 1570 | const runtimeEvents = mapToRuntimeEvents(event, event.threadId); |
| 1571 | if (runtimeEvents.length === 0) { |
| 1572 | yield* Effect.logDebug("ignoring unhandled Codex provider event", { |
| 1573 | method: event.method, |
| 1574 | threadId: event.threadId, |
| 1575 | turnId: event.turnId, |
| 1576 | itemId: event.itemId, |
| 1577 | }); |
| 1578 | return; |
| 1579 | } |
| 1580 | yield* Queue.offerAll(runtimeEventQueue, runtimeEvents); |
| 1581 | }); |
| 1582 | const listener = (event: ProviderEvent) => |
| 1583 | listenerEffect(event).pipe(Effect.runPromiseWith(services)); |
| 1584 | manager.on("event", listener); |
| 1585 | return listener; |
| 1586 | }); |
| 1587 | |
| 1588 | const unregisterListener = Effect.fn("unregisterListener")(function* ( |
| 1589 | listener: (event: ProviderEvent) => Promise<void>, |
| 1590 | ) { |
| 1591 | yield* Effect.sync(() => { |
| 1592 | manager.off("event", listener); |
| 1593 | }); |
| 1594 | yield* Queue.shutdown(runtimeEventQueue); |
| 1613 | 1595 | }); |
| 1614 | 1596 | |
| 1597 | yield* Effect.acquireRelease(registerListener(), unregisterListener); |
| 1598 | |
| 1599 | return { |
| 1600 | provider: PROVIDER, |
| 1601 | capabilities: { |
| 1602 | sessionModelSwitch: "in-session", |
| 1603 | }, |
| 1604 | startSession, |
| 1605 | sendTurn, |
| 1606 | interruptTurn, |
| 1607 | readThread, |
| 1608 | rollbackThread, |
| 1609 | respondToRequest, |
| 1610 | respondToUserInput, |
| 1611 | stopSession, |
| 1612 | listSessions, |
| 1613 | hasSession, |
| 1614 | stopAll, |
| 1615 | streamEvents: Stream.fromQueue(runtimeEventQueue), |
| 1616 | } satisfies CodexAdapterShape; |
| 1617 | }); |
| 1618 | |
| 1615 | 1619 | export const CodexAdapterLive = Layer.effect(CodexAdapter, makeCodexAdapter()); |
| 1616 | 1620 | |
| 1617 | 1621 | export function makeCodexAdapterLive(options?: CodexAdapterLiveOptions) { |