From 0c50d86b55f37577a7b7af3469ab58343c377f8f Mon Sep 17 00:00:00 2001
From: Eric Hayes
Date: Tue, 4 Aug 2026 11:46:32 -0400
Subject: [PATCH] feat: agent-turn placeholder comms messages
---
...023bec273d62d6c2f233c3236ffebbfaf5810.json | 2 +-
...5a2602f57513f10c4bf6982348b4d5e2f6111.json | 22 +
...aee16a03bc33c37c4cdf2a1a80b5f2d8e5f00.json | 77 ---
...4dd3a6200562694046817cbfadef2f6d67082.json | 2 +-
...a73d1fd4ab5bcb96637eb82399adc1c3eb4fd.json | 22 +
...8d756f2001fb03c1e389dabb596d95359ed94.json | 2 +-
...29d29e94abe7ec9ad2574d53e138413e049cd.json | 2 +-
...85b41dc7091d94c7f36bccd746db1a3e74ca6.json | 34 --
...ff988858be56a49bc76882111ac7a6e0dd5f7.json | 89 ---
...816b4e44a42060eef49957e88bbfdf005a71c.json | 17 +
...aa6072ef35b83179314e72df82255c6ff5aa.json} | 18 +-
...2985f871627c81e7a82c52c18e37a0e3c176d.json | 83 +++
...da4e6e89dae702e5b50269db6e17265d14643.json | 2 +-
...f001b1da7b88bcd175929f42b88146d4707f8.json | 2 +-
...f4fe9c1da7b19594f1b7dc59f2ed0a613dec1.json | 2 +-
...ea4c76af870f45df004ab8c5626d73a664acc.json | 22 -
...4dfd5098ae5230ef3ccf7cbe4b091fb995b46.json | 20 -
...24623fd176727a6514b7a475d8b4df1f7003.json} | 18 +-
...a17fb223a8163faddf8d3b12926941cba8ed.json} | 8 +-
...73e692b94d2f3df42523f6146f995d767212.json} | 18 +-
...290e0e89ba9521fbba575634fd92a39433271.json | 2 +-
...31b621173c04bfda9678021360817c3f25103.json | 2 +-
...c8ae86c6f090cbfd28f22cd952756950a94db.json | 2 +-
...7d8559171dacf2675417c2318cd4dd468ce94.json | 82 ---
...ee4ce16c75b9cfd1bca5094a99a9a8462b23.json} | 4 +-
...bb6dbca234573258ab89a71ec2d9b2aba6f5.json} | 18 +-
...3a857d2ea88043f3557137fb2718e64661ce0.json | 82 ---
...d94ba013c0f54f111ed98b3f70f8fa4e89cd.json} | 16 +-
...f3365a7943c2c34433980ccc94817f219b3ee.json | 28 -
...dcd01ea358e68c65fbf08888c1b3cd1fb907.json} | 18 +-
...5213bcc7843c5c967093ab561405411b71d53.json | 16 -
...e10559aba3d70c5bb25b2fe7edf1a8986fa06.json | 88 +++
...90910f5be0d1084e23ffe8e56c2f20015e61d.json | 17 -
...3dc74c98c82cfd7c7bf32cd683b160823e8ac.json | 15 -
...54320d566bd069ffd66d69142cd59c610a990.json | 88 +++
...ee3364dbb75bdc22047789492b8f73b3e2491.json | 2 +-
...19e0d6bef4a3d9ea2791d75799c93b4f2350f.json | 2 +-
...4c18a686779bfe3a08cb83ee2130ec472fe3.json} | 18 +-
Cargo.lock | 8 +-
.../src/features/channel/Channel/Channel.tsx | 527 +++++++++--------
.../Channel/create-channel-message-actions.ts | 2 +-
.../channel/Channel/message-editing.ts | 2 +-
.../channel/Message/ChannelMessage.tsx | 51 +-
.../channel/Message/FoldedContent.tsx | 136 +++++
.../src/features/channel/Message/context.ts | 8 +
.../web/src/features/channel/Message/index.ts | 4 +
.../web/src/features/channel/Message/types.ts | 8 +-
.../StandaloneThread/EditableThread.tsx | 2 +-
.../lib/core/component/DocumentPreview.tsx | 2 +-
.../lib/queries/channel/folded-messages.ts | 63 ++
apps/web/src/lib/queries/channel/keys.ts | 3 +
apps/web/src/lib/queries/channel/message.ts | 2 +-
apps/web/src/lib/queries/channel/reconcile.ts | 4 +-
apps/web/src/lib/queries/channel/sync.ts | 2 +-
.../src/lib/queries/soup/transform-utils.ts | 6 +-
.../service-agent-proxy/generated/client.ts | 68 +++
.../schemas/connectAgentRuntimeParams.ts | 13 +
.../generated/schemas/entityType.ts | 1 +
.../generated/schemas/index.ts | 1 +
.../service-agent-proxy/openapi.json | 29 +
.../service-storage/channel-list-types.ts | 3 +
.../service-clients/service-storage/client.ts | 16 +
.../schemas/agentChannelMessagesResponse.ts | 17 +
.../schemas/apiAttachmentChannelReference.ts | 6 +-
...ttachmentChannelReferenceMessageContent.ts | 12 +
.../schemas/apiChannelContextMessage.ts | 9 +-
...nnelContextMessageAgentSessionMessageId.ts | 12 +
.../apiChannelContextMessageContent.ts | 11 +
.../generated/schemas/apiChannelKind.ts | 18 +
.../schemas/apiChannelListMessage.ts | 5 +-
.../schemas/apiChannelListMessageContent.ts | 11 +
.../generated/schemas/apiChannelMessage.ts | 12 +-
.../apiChannelMessageAgentSessionMessageId.ts | 13 +
.../schemas/apiChannelMessageContent.ts | 12 +
.../generated/schemas/apiChannelWithLatest.ts | 4 +
.../generated/schemas/apiThreadReply.ts | 9 +-
.../apiThreadReplyAgentSessionMessageId.ts | 12 +
.../schemas/apiThreadReplyContent.ts | 11 +
.../generated/schemas/channelMessage.ts | 5 +-
.../schemas/channelMessageContent.ts | 11 +
.../generated/schemas/fileDiffDto.ts | 19 +
.../generated/schemas/fileDiffDtoOldText.ts | 11 +
.../generated/schemas/foldedAuthorDto.ts | 18 +
.../generated/schemas/foldedAuthorDtoOneOf.ts | 17 +
.../schemas/foldedAuthorDtoOneOfFour.ts | 14 +
.../schemas/foldedAuthorDtoOneOfFourKind.ts | 14 +
.../schemas/foldedAuthorDtoOneOfKind.ts | 14 +
.../schemas/foldedAuthorDtoOneOfUserId.ts | 11 +
.../generated/schemas/foldedMessageDto.ts | 31 +
.../generated/schemas/foldedMessageDtoStop.ts | 9 +
.../generated/schemas/foldedMessagePartDto.ts | 19 +
.../schemas/foldedMessagePartDtoOneOf.ts | 16 +
.../schemas/foldedMessagePartDtoOneOfFive.ts | 25 +
.../foldedMessagePartDtoOneOfFiveKind.ts | 14 +
.../schemas/foldedMessagePartDtoOneOfKind.ts | 14 +
.../schemas/foldedMessagePartDtoOneOfSeven.ts | 21 +
.../foldedMessagePartDtoOneOfSevenKind.ts | 14 +
.../foldedMessagePartDtoOneOfSevenOutcome.ts | 9 +
.../schemas/foldedMessagePartDtoOneOfThree.ts | 16 +
.../foldedMessagePartDtoOneOfThreeKind.ts | 14 +
.../generated/schemas/index.ts | 67 +++
.../generated/schemas/permissionOptionDto.ts | 20 +
.../generated/schemas/permissionOutcomeDto.ts | 15 +
.../schemas/permissionOutcomeDtoOneOf.ts | 16 +
.../schemas/permissionOutcomeDtoOneOfKind.ts | 14 +
.../schemas/permissionOutcomeDtoOneOfThree.ts | 14 +
.../permissionOutcomeDtoOneOfThreeKind.ts | 14 +
.../generated/schemas/soupChannelThread.ts | 5 +-
.../schemas/soupChannelThreadContent.ts | 11 +
.../generated/schemas/soupThreadReply.ts | 5 +-
.../schemas/soupThreadReplyContent.ts | 11 +
.../generated/schemas/stopReasonDto.ts | 23 +
.../generated/schemas/stopReasonDtoOneOf.ts | 14 +
.../schemas/stopReasonDtoOneOfFive.ts | 14 +
.../schemas/stopReasonDtoOneOfFiveKind.ts | 14 +
.../schemas/stopReasonDtoOneOfKind.ts | 14 +
.../schemas/stopReasonDtoOneOfNine.ts | 14 +
.../schemas/stopReasonDtoOneOfNineKind.ts | 14 +
.../schemas/stopReasonDtoOneOfOneone.ts | 16 +
.../schemas/stopReasonDtoOneOfOneoneKind.ts | 14 +
.../schemas/stopReasonDtoOneOfSeven.ts | 14 +
.../schemas/stopReasonDtoOneOfSevenKind.ts | 14 +
.../schemas/stopReasonDtoOneOfThree.ts | 14 +
.../schemas/stopReasonDtoOneOfThreeKind.ts | 14 +
.../generated/schemas/toolDetailDto.ts | 19 +
.../generated/schemas/toolDetailDtoOneOf.ts | 23 +
.../schemas/toolDetailDtoOneOfCommand.ts | 11 +
.../schemas/toolDetailDtoOneOfEight.ts | 16 +
.../schemas/toolDetailDtoOneOfEightKind.ts | 14 +
.../schemas/toolDetailDtoOneOfExitCode.ts | 11 +
.../schemas/toolDetailDtoOneOfKind.ts | 14 +
.../schemas/toolDetailDtoOneOfOnezero.ts | 19 +
.../schemas/toolDetailDtoOneOfOnezeroInput.ts | 13 +
.../toolDetailDtoOneOfOnezeroInputAnyOf.ts | 11 +
.../schemas/toolDetailDtoOneOfOnezeroKind.ts | 14 +
.../schemas/toolDetailDtoOneOfOutput.ts | 11 +
.../schemas/toolDetailDtoOneOfSix.ts | 17 +
.../schemas/toolDetailDtoOneOfSixKind.ts | 14 +
.../generated/schemas/toolStatusDto.ts | 19 +
.../service-storage/generated/zod.ts | 523 ++++++++++++++++-
.../service-storage/openapi.json | 551 +++++++++++++++++-
crates/agent_fold/Cargo.toml | 9 +-
crates/agent_fold/examples/fold_recording.rs | 8 +-
crates/agent_fold/src/bin/fold_jsonl.rs | 5 +-
crates/agent_fold/src/domain/fold.rs | 7 +-
crates/agent_fold/src/domain/log.rs | 80 +++
crates/agent_fold/src/domain/mod.rs | 2 +
crates/agent_fold/src/domain/model.rs | 54 +-
crates/agent_fold/src/domain/ports.rs | 30 +-
crates/agent_fold/src/domain/service.rs | 13 +-
crates/agent_fold/src/domain/test/fold.rs | 2 +-
crates/agent_fold/src/domain/test/service.rs | 50 +-
crates/agent_fold/src/domain/test/util.rs | 89 +--
crates/agent_fold/src/lib.rs | 27 +-
crates/agent_fold/src/testing.rs | 125 ++++
crates/agent_harness/Cargo.toml | 1 +
crates/agent_harness/src/domain/ports/test.rs | 81 ++-
.../agent_harness/src/domain/service/test.rs | 15 +-
crates/agent_session/Cargo.toml | 7 +-
crates/agent_session/src/bin/seed_jsonl.rs | 301 ++++++++++
.../src/bin/sync_placeholders.rs | 88 +++
crates/agent_session/src/domain/error.rs | 10 +
crates/agent_session/src/domain/model.rs | 101 ++--
crates/agent_session/src/domain/ports.rs | 35 ++
crates/agent_session/src/domain/service.rs | 238 +++++++-
.../agent_session/src/domain/service/test.rs | 321 ++++++++++
.../agent_session/src/inbound/axum_router.rs | 413 ++++++++++++-
.../src/outbound/postgres/mod.rs | 110 +++-
crates/agent_session/src/testing.rs | 104 +++-
crates/bots/src/inbound/axum_router/tests.rs | 1 +
crates/channel_bots/src/domain/service.rs | 44 +-
.../channel_bots/src/domain/service/tests.rs | 8 +-
crates/channels/src/domain/models.rs | 73 ++-
crates/channels/src/domain/service.rs | 3 +
crates/channels/src/domain/service/test.rs | 25 +-
crates/channels/src/domain/side_effects.rs | 16 +-
.../channels/src/domain/side_effects/test.rs | 30 +-
crates/channels/src/inbound/attachment.rs | 2 +-
crates/channels/src/inbound/axum_router.rs | 31 +-
.../channels/src/inbound/axum_router/test.rs | 8 +-
crates/channels/src/inbound/list_router.rs | 36 +-
.../channels/src/inbound/list_router/test.rs | 1 +
crates/channels/src/inbound/toolset/types.rs | 10 +-
.../connection_gateway_realtime/test.rs | 2 +-
.../channels/src/outbound/pg_channels_repo.rs | 95 +--
.../src/outbound/pg_channels_repo/tests.rs | 31 +-
.../src/messages/get_channel_message.rs | 2 +-
crates/comms_db_client/src/model/mod.rs | 4 +-
crates/graphql_soup/src/objects.rs | 12 +-
...comms_messages_agent_session_turn.down.sql | 10 +
...2_comms_messages_agent_session_turn.up.sql | 20 +
...messages_agent_session_message_id.down.sql | 14 +
...s_messages_agent_session_message_id.up.sql | 27 +
crates/models_soup/src/comms.rs | 12 +-
crates/soup/src/domain/service/tests.rs | 6 +-
services/agent_harness_service/Cargo.toml | 1 +
services/agent_harness_service/src/main.rs | 12 +-
services/document_storage_service/Cargo.toml | 1 +
.../src/api/context.rs | 14 +-
.../src/api/swagger.rs | 12 +
services/document_storage_service/src/main.rs | 8 +-
.../src/process/channel.rs | 8 +-
202 files changed, 5774 insertions(+), 1249 deletions(-)
create mode 100644 .sqlx/query-07fc08bbaee6ce5b956ce3bc7775a2602f57513f10c4bf6982348b4d5e2f6111.json
delete mode 100644 .sqlx/query-109fd3ea2a92055d4f0d2e27727aee16a03bc33c37c4cdf2a1a80b5f2d8e5f00.json
create mode 100644 .sqlx/query-17058fd41e0f883956fcc2e5653a73d1fd4ab5bcb96637eb82399adc1c3eb4fd.json
delete mode 100644 .sqlx/query-36780455e00455710636eedc15385b41dc7091d94c7f36bccd746db1a3e74ca6.json
delete mode 100644 .sqlx/query-3a8adccd1178690a421baa6b963ff988858be56a49bc76882111ac7a6e0dd5f7.json
create mode 100644 .sqlx/query-423a324d66e3ef181aa716c61c0816b4e44a42060eef49957e88bbfdf005a71c.json
rename .sqlx/{query-6220297009a6be311ae935bd69ad9a2235af5d38bf380330fa1b2d40777a3b53.json => query-433421d2c2014c21de08b9a9852baa6072ef35b83179314e72df82255c6ff5aa.json} (61%)
create mode 100644 .sqlx/query-43d832a38422b97bbc6dfd3447f2985f871627c81e7a82c52c18e37a0e3c176d.json
delete mode 100644 .sqlx/query-616364e2fe05cf29270eca89e09ea4c76af870f45df004ab8c5626d73a664acc.json
delete mode 100644 .sqlx/query-650316b3bc9215d0fc4937a31834dfd5098ae5230ef3ccf7cbe4b091fb995b46.json
rename .sqlx/{query-d82d977779927364228d9652cd1958df1fa31ba45ef06790f540139098e3d10f.json => query-68333330d48632bfe4b7c8f5fe8424623fd176727a6514b7a475d8b4df1f7003.json} (73%)
rename .sqlx/{query-993f8bd336bc9ba45ab51edac700887af626bf74de28e395ab8a1875bc9fe9ac.json => query-78bc7a6beb2f3935f8df07744673a17fb223a8163faddf8d3b12926941cba8ed.json} (58%)
rename .sqlx/{query-725e8efb816dd4f03c34db74c141dc0929de9df06ec14e4c7eb93409a0bd0de8.json => query-7dce78a74f433b12441f5f36243273e692b94d2f3df42523f6146f995d767212.json} (70%)
delete mode 100644 .sqlx/query-96f2482e829992f905137782aa37d8559171dacf2675417c2318cd4dd468ce94.json
rename .sqlx/{query-7ee9ba5adfac304c7ac930943538ecca7428b1054d451fb2f8973cf9c11caf45.json => query-b1e404833387b705eb009bf8b7e4ee4ce16c75b9cfd1bca5094a99a9a8462b23.json} (71%)
rename .sqlx/{query-ec3636e62ff865a5c6808e8f87d65730837b043b9cb69ce7075c431cee32b58b.json => query-bfd39d96197c12a509926cb3af2fbb6dbca234573258ab89a71ec2d9b2aba6f5.json} (61%)
delete mode 100644 .sqlx/query-c3a21ba551a400f7dbdd9f80f7f3a857d2ea88043f3557137fb2718e64661ce0.json
rename .sqlx/{query-246d88f16e6bac8064be41148528e0545be33e3c43a3dc5193ab12220f8d8a7c.json => query-c81dabe48269c198cfebc1287d92d94ba013c0f54f111ed98b3f70f8fa4e89cd.json} (69%)
delete mode 100644 .sqlx/query-caab9ca115f32ea6049a55e8dcff3365a7943c2c34433980ccc94817f219b3ee.json
rename .sqlx/{query-fbee16f91442381c51b85d5e77042ba3787eec094e62746727249f3055ec2dbb.json => query-cfd656f6388b4aded4610eb39cbadcd01ea358e68c65fbf08888c1b3cd1fb907.json} (65%)
delete mode 100644 .sqlx/query-d0b7d158347cac8d4dfc1ca77105213bcc7843c5c967093ab561405411b71d53.json
create mode 100644 .sqlx/query-d4e902b7511e62d58cc34fe9fede10559aba3d70c5bb25b2fe7edf1a8986fa06.json
delete mode 100644 .sqlx/query-d786a1b974bb723a269cb31b36190910f5be0d1084e23ffe8e56c2f20015e61d.json
delete mode 100644 .sqlx/query-e18bdb73d69d37ea32745f55c613dc74c98c82cfd7c7bf32cd683b160823e8ac.json
create mode 100644 .sqlx/query-e6527904d07cf679c44b58649f354320d566bd069ffd66d69142cd59c610a990.json
rename .sqlx/{query-b002a549f1a74fd65b2a24564ef8e35fa56a985e1f8eef8265139dd4a2777440.json => query-fcdf7298d2531cba0e94412709534c18a686779bfe3a08cb83ee2130ec472fe3.json} (70%)
create mode 100644 apps/web/src/features/channel/Message/FoldedContent.tsx
create mode 100644 apps/web/src/lib/queries/channel/folded-messages.ts
create mode 100644 apps/web/src/lib/service-clients/service-agent-proxy/generated/schemas/connectAgentRuntimeParams.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/agentChannelMessagesResponse.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/apiAttachmentChannelReferenceMessageContent.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelContextMessageAgentSessionMessageId.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelContextMessageContent.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelKind.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelListMessageContent.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelMessageAgentSessionMessageId.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelMessageContent.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/apiThreadReplyAgentSessionMessageId.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/apiThreadReplyContent.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/channelMessageContent.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/fileDiffDto.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/fileDiffDtoOldText.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedAuthorDto.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedAuthorDtoOneOf.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedAuthorDtoOneOfFour.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedAuthorDtoOneOfFourKind.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedAuthorDtoOneOfKind.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedAuthorDtoOneOfUserId.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessageDto.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessageDtoStop.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDto.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOf.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfFive.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfFiveKind.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfKind.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfSeven.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfSevenKind.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfSevenOutcome.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfThree.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfThreeKind.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/permissionOptionDto.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/permissionOutcomeDto.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/permissionOutcomeDtoOneOf.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/permissionOutcomeDtoOneOfKind.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/permissionOutcomeDtoOneOfThree.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/permissionOutcomeDtoOneOfThreeKind.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/soupChannelThreadContent.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/soupThreadReplyContent.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDto.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOf.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfFive.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfFiveKind.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfKind.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfNine.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfNineKind.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfOneone.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfOneoneKind.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfSeven.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfSevenKind.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfThree.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfThreeKind.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDto.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOf.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfCommand.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfEight.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfEightKind.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfExitCode.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfKind.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfOnezero.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfOnezeroInput.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfOnezeroInputAnyOf.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfOnezeroKind.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfOutput.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfSix.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfSixKind.ts
create mode 100644 apps/web/src/lib/service-clients/service-storage/generated/schemas/toolStatusDto.ts
create mode 100644 crates/agent_fold/src/domain/log.rs
create mode 100644 crates/agent_fold/src/testing.rs
create mode 100644 crates/agent_session/src/bin/seed_jsonl.rs
create mode 100644 crates/agent_session/src/bin/sync_placeholders.rs
create mode 100644 crates/agent_session/src/domain/service/test.rs
create mode 100644 crates/macro_db_client/migrations/20260804222432_comms_messages_agent_session_turn.down.sql
create mode 100644 crates/macro_db_client/migrations/20260804222432_comms_messages_agent_session_turn.up.sql
create mode 100644 crates/macro_db_client/migrations/20260806202542_comms_messages_agent_session_message_id.down.sql
create mode 100644 crates/macro_db_client/migrations/20260806202542_comms_messages_agent_session_message_id.up.sql
diff --git a/.sqlx/query-000dcd3c4befc3975adf3bee932023bec273d62d6c2f233c3236ffebbfaf5810.json b/.sqlx/query-000dcd3c4befc3975adf3bee932023bec273d62d6c2f233c3236ffebbfaf5810.json
index 02036afbf1a..fd152837f5b 100644
--- a/.sqlx/query-000dcd3c4befc3975adf3bee932023bec273d62d6c2f233c3236ffebbfaf5810.json
+++ b/.sqlx/query-000dcd3c4befc3975adf3bee932023bec273d62d6c2f233c3236ffebbfaf5810.json
@@ -57,7 +57,7 @@
false,
true,
false,
- false,
+ true,
false,
false
]
diff --git a/.sqlx/query-07fc08bbaee6ce5b956ce3bc7775a2602f57513f10c4bf6982348b4d5e2f6111.json b/.sqlx/query-07fc08bbaee6ce5b956ce3bc7775a2602f57513f10c4bf6982348b4d5e2f6111.json
new file mode 100644
index 00000000000..1c437e41b4c
--- /dev/null
+++ b/.sqlx/query-07fc08bbaee6ce5b956ce3bc7775a2602f57513f10c4bf6982348b4d5e2f6111.json
@@ -0,0 +1,22 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT user_id\n FROM comms_channel_participants\n WHERE channel_id = $1 AND role = 'owner'\n LIMIT 1\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "user_id",
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Uuid"
+ ]
+ },
+ "nullable": [
+ false
+ ]
+ },
+ "hash": "07fc08bbaee6ce5b956ce3bc7775a2602f57513f10c4bf6982348b4d5e2f6111"
+}
diff --git a/.sqlx/query-109fd3ea2a92055d4f0d2e27727aee16a03bc33c37c4cdf2a1a80b5f2d8e5f00.json b/.sqlx/query-109fd3ea2a92055d4f0d2e27727aee16a03bc33c37c4cdf2a1a80b5f2d8e5f00.json
deleted file mode 100644
index ac5596a2bda..00000000000
--- a/.sqlx/query-109fd3ea2a92055d4f0d2e27727aee16a03bc33c37c4cdf2a1a80b5f2d8e5f00.json
+++ /dev/null
@@ -1,77 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "\n SELECT\n id AS \"id!\", thread_id AS \"thread_id!\", sender_id AS \"sender_id!\",\n triggered_by_user_id,\n content AS \"content!\", created_at AS \"created_at!\", updated_at AS \"updated_at!\",\n edited_at::timestamptz AS \"edited_at?\",\n reply_count AS \"reply_count!\", latest_reply_at AS \"latest_reply_at?\"\n FROM (\n SELECT\n r.id,\n r.thread_id,\n r.sender_id,\n r.triggered_by_user_id,\n r.content,\n r.created_at,\n r.updated_at,\n r.edited_at,\n COUNT(*) OVER (PARTITION BY r.thread_id) AS reply_count,\n MAX(r.created_at) OVER (PARTITION BY r.thread_id)::timestamptz AS latest_reply_at,\n ROW_NUMBER() OVER (\n PARTITION BY r.thread_id\n ORDER BY r.created_at ASC, r.id ASC\n ) AS rn\n FROM comms_messages r\n WHERE r.thread_id = ANY($1) AND r.deleted_at IS NULL\n ) sub\n WHERE rn <= $2\n ORDER BY thread_id, created_at ASC, id ASC\n ",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "id!",
- "type_info": "Uuid"
- },
- {
- "ordinal": 1,
- "name": "thread_id!",
- "type_info": "Uuid"
- },
- {
- "ordinal": 2,
- "name": "sender_id!",
- "type_info": "Text"
- },
- {
- "ordinal": 3,
- "name": "triggered_by_user_id",
- "type_info": "Text"
- },
- {
- "ordinal": 4,
- "name": "content!",
- "type_info": "Text"
- },
- {
- "ordinal": 5,
- "name": "created_at!",
- "type_info": "Timestamptz"
- },
- {
- "ordinal": 6,
- "name": "updated_at!",
- "type_info": "Timestamptz"
- },
- {
- "ordinal": 7,
- "name": "edited_at?",
- "type_info": "Timestamptz"
- },
- {
- "ordinal": 8,
- "name": "reply_count!",
- "type_info": "Int8"
- },
- {
- "ordinal": 9,
- "name": "latest_reply_at?",
- "type_info": "Timestamptz"
- }
- ],
- "parameters": {
- "Left": [
- "UuidArray",
- "Int8"
- ]
- },
- "nullable": [
- false,
- true,
- false,
- true,
- false,
- false,
- false,
- null,
- null,
- null
- ]
- },
- "hash": "109fd3ea2a92055d4f0d2e27727aee16a03bc33c37c4cdf2a1a80b5f2d8e5f00"
-}
diff --git a/.sqlx/query-11fa9f8cff223ea598b05cb8d734dd3a6200562694046817cbfadef2f6d67082.json b/.sqlx/query-11fa9f8cff223ea598b05cb8d734dd3a6200562694046817cbfadef2f6d67082.json
index 873e1e07064..704c473bc24 100644
--- a/.sqlx/query-11fa9f8cff223ea598b05cb8d734dd3a6200562694046817cbfadef2f6d67082.json
+++ b/.sqlx/query-11fa9f8cff223ea598b05cb8d734dd3a6200562694046817cbfadef2f6d67082.json
@@ -69,7 +69,7 @@
false,
false,
true,
- false,
+ true,
false,
false,
true,
diff --git a/.sqlx/query-17058fd41e0f883956fcc2e5653a73d1fd4ab5bcb96637eb82399adc1c3eb4fd.json b/.sqlx/query-17058fd41e0f883956fcc2e5653a73d1fd4ab5bcb96637eb82399adc1c3eb4fd.json
new file mode 100644
index 00000000000..f6579544fe8
--- /dev/null
+++ b/.sqlx/query-17058fd41e0f883956fcc2e5653a73d1fd4ab5bcb96637eb82399adc1c3eb4fd.json
@@ -0,0 +1,22 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT agent_session_message_id AS \"agent_session_message_id!\"\n FROM comms_messages\n WHERE channel_id = $1 AND agent_session_message_id IS NOT NULL\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "agent_session_message_id!",
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Uuid"
+ ]
+ },
+ "nullable": [
+ true
+ ]
+ },
+ "hash": "17058fd41e0f883956fcc2e5653a73d1fd4ab5bcb96637eb82399adc1c3eb4fd"
+}
diff --git a/.sqlx/query-17c240f6a460b4c63a40f0ab96f8d756f2001fb03c1e389dabb596d95359ed94.json b/.sqlx/query-17c240f6a460b4c63a40f0ab96f8d756f2001fb03c1e389dabb596d95359ed94.json
index 3c25cdad6ec..b3131bb9868 100644
--- a/.sqlx/query-17c240f6a460b4c63a40f0ab96f8d756f2001fb03c1e389dabb596d95359ed94.json
+++ b/.sqlx/query-17c240f6a460b4c63a40f0ab96f8d756f2001fb03c1e389dabb596d95359ed94.json
@@ -66,7 +66,7 @@
false,
false,
true,
- false,
+ true,
false,
false,
true,
diff --git a/.sqlx/query-256ccfc280aaca7d1eff2bd993b29d29e94abe7ec9ad2574d53e138413e049cd.json b/.sqlx/query-256ccfc280aaca7d1eff2bd993b29d29e94abe7ec9ad2574d53e138413e049cd.json
index 17701131fda..0e30f547a6b 100644
--- a/.sqlx/query-256ccfc280aaca7d1eff2bd993b29d29e94abe7ec9ad2574d53e138413e049cd.json
+++ b/.sqlx/query-256ccfc280aaca7d1eff2bd993b29d29e94abe7ec9ad2574d53e138413e049cd.json
@@ -65,7 +65,7 @@
false,
false,
true,
- false,
+ true,
false,
false,
true,
diff --git a/.sqlx/query-36780455e00455710636eedc15385b41dc7091d94c7f36bccd746db1a3e74ca6.json b/.sqlx/query-36780455e00455710636eedc15385b41dc7091d94c7f36bccd746db1a3e74ca6.json
deleted file mode 100644
index 9726cccf964..00000000000
--- a/.sqlx/query-36780455e00455710636eedc15385b41dc7091d94c7f36bccd746db1a3e74ca6.json
+++ /dev/null
@@ -1,34 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "\n SELECT user_id, role::text AS \"role!\", left_at\n FROM comms_channel_participants\n WHERE channel_id = $1\n ",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "user_id",
- "type_info": "Text"
- },
- {
- "ordinal": 1,
- "name": "role!",
- "type_info": "Text"
- },
- {
- "ordinal": 2,
- "name": "left_at",
- "type_info": "Timestamptz"
- }
- ],
- "parameters": {
- "Left": [
- "Uuid"
- ]
- },
- "nullable": [
- false,
- null,
- true
- ]
- },
- "hash": "36780455e00455710636eedc15385b41dc7091d94c7f36bccd746db1a3e74ca6"
-}
diff --git a/.sqlx/query-3a8adccd1178690a421baa6b963ff988858be56a49bc76882111ac7a6e0dd5f7.json b/.sqlx/query-3a8adccd1178690a421baa6b963ff988858be56a49bc76882111ac7a6e0dd5f7.json
deleted file mode 100644
index 30ac9b0d308..00000000000
--- a/.sqlx/query-3a8adccd1178690a421baa6b963ff988858be56a49bc76882111ac7a6e0dd5f7.json
+++ /dev/null
@@ -1,89 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "\n SELECT\n id, created_from_thread_id, thread_id, bot_id, model, harness,\n repo_url, acp_session_id, status, status_event_name, created_at, modified_at\n FROM agent_session\n WHERE bot_id = $1\n AND (created_from_thread_id = $2 OR thread_id = $2)\n ORDER BY created_at DESC\n LIMIT 1\n ",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "id",
- "type_info": "Uuid"
- },
- {
- "ordinal": 1,
- "name": "created_from_thread_id",
- "type_info": "Uuid"
- },
- {
- "ordinal": 2,
- "name": "thread_id",
- "type_info": "Uuid"
- },
- {
- "ordinal": 3,
- "name": "bot_id",
- "type_info": "Uuid"
- },
- {
- "ordinal": 4,
- "name": "model",
- "type_info": "Text"
- },
- {
- "ordinal": 5,
- "name": "harness",
- "type_info": "Text"
- },
- {
- "ordinal": 6,
- "name": "repo_url",
- "type_info": "Text"
- },
- {
- "ordinal": 7,
- "name": "acp_session_id",
- "type_info": "Text"
- },
- {
- "ordinal": 8,
- "name": "status",
- "type_info": "Text"
- },
- {
- "ordinal": 9,
- "name": "status_event_name",
- "type_info": "Text"
- },
- {
- "ordinal": 10,
- "name": "created_at",
- "type_info": "Timestamptz"
- },
- {
- "ordinal": 11,
- "name": "modified_at",
- "type_info": "Timestamptz"
- }
- ],
- "parameters": {
- "Left": [
- "Uuid",
- "Uuid"
- ]
- },
- "nullable": [
- false,
- true,
- false,
- false,
- false,
- false,
- false,
- true,
- false,
- true,
- false,
- false
- ]
- },
- "hash": "3a8adccd1178690a421baa6b963ff988858be56a49bc76882111ac7a6e0dd5f7"
-}
diff --git a/.sqlx/query-423a324d66e3ef181aa716c61c0816b4e44a42060eef49957e88bbfdf005a71c.json b/.sqlx/query-423a324d66e3ef181aa716c61c0816b4e44a42060eef49957e88bbfdf005a71c.json
new file mode 100644
index 00000000000..5d81ff1b478
--- /dev/null
+++ b/.sqlx/query-423a324d66e3ef181aa716c61c0816b4e44a42060eef49957e88bbfdf005a71c.json
@@ -0,0 +1,17 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n INSERT INTO comms_messages (id, channel_id, sender_id, agent_session_message_id)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (agent_session_message_id) WHERE agent_session_message_id IS NOT NULL\n DO NOTHING\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Uuid",
+ "Uuid",
+ "Text",
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "423a324d66e3ef181aa716c61c0816b4e44a42060eef49957e88bbfdf005a71c"
+}
diff --git a/.sqlx/query-6220297009a6be311ae935bd69ad9a2235af5d38bf380330fa1b2d40777a3b53.json b/.sqlx/query-433421d2c2014c21de08b9a9852baa6072ef35b83179314e72df82255c6ff5aa.json
similarity index 61%
rename from .sqlx/query-6220297009a6be311ae935bd69ad9a2235af5d38bf380330fa1b2d40777a3b53.json
rename to .sqlx/query-433421d2c2014c21de08b9a9852baa6072ef35b83179314e72df82255c6ff5aa.json
index 1626fcf87d1..57e984642ab 100644
--- a/.sqlx/query-6220297009a6be311ae935bd69ad9a2235af5d38bf380330fa1b2d40777a3b53.json
+++ b/.sqlx/query-433421d2c2014c21de08b9a9852baa6072ef35b83179314e72df82255c6ff5aa.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n SELECT\n m.id,\n m.channel_id,\n m.sender_id,\n m.triggered_by_user_id,\n m.content,\n m.created_at,\n m.updated_at,\n m.edited_at::timestamptz AS \"edited_at?\",\n m.deleted_at::timestamptz AS \"deleted_at?\"\n FROM comms_messages m\n WHERE m.channel_id = $1\n AND m.thread_id IS NULL\n AND (m.deleted_at IS NULL OR EXISTS (\n SELECT 1 FROM comms_messages r\n WHERE r.thread_id = m.id AND r.deleted_at IS NULL\n ))\n AND (m.created_at, m.id) > ($2, $3)\n ORDER BY m.created_at ASC, m.id ASC\n LIMIT $4\n ",
+ "query": "\n SELECT\n m.id,\n m.channel_id,\n m.sender_id,\n m.triggered_by_user_id,\n m.content,\n m.agent_session_message_id,\n m.created_at,\n m.updated_at,\n m.edited_at::timestamptz AS \"edited_at?\",\n m.deleted_at::timestamptz AS \"deleted_at?\"\n FROM comms_messages m\n WHERE m.channel_id = $1\n AND m.thread_id IS NULL\n AND (m.deleted_at IS NULL OR EXISTS (\n SELECT 1 FROM comms_messages r\n WHERE r.thread_id = m.id AND r.deleted_at IS NULL\n ))\n AND (m.created_at, m.id) < ($2, $3)\n ORDER BY m.created_at DESC, m.id DESC\n LIMIT $4\n ",
"describe": {
"columns": [
{
@@ -30,21 +30,26 @@
},
{
"ordinal": 5,
+ "name": "agent_session_message_id",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 6,
"name": "created_at",
"type_info": "Timestamptz"
},
{
- "ordinal": 6,
+ "ordinal": 7,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
- "ordinal": 7,
+ "ordinal": 8,
"name": "edited_at?",
"type_info": "Timestamptz"
},
{
- "ordinal": 8,
+ "ordinal": 9,
"name": "deleted_at?",
"type_info": "Timestamptz"
}
@@ -62,12 +67,13 @@
false,
false,
true,
- false,
+ true,
+ true,
false,
false,
null,
null
]
},
- "hash": "6220297009a6be311ae935bd69ad9a2235af5d38bf380330fa1b2d40777a3b53"
+ "hash": "433421d2c2014c21de08b9a9852baa6072ef35b83179314e72df82255c6ff5aa"
}
diff --git a/.sqlx/query-43d832a38422b97bbc6dfd3447f2985f871627c81e7a82c52c18e37a0e3c176d.json b/.sqlx/query-43d832a38422b97bbc6dfd3447f2985f871627c81e7a82c52c18e37a0e3c176d.json
new file mode 100644
index 00000000000..c5330510791
--- /dev/null
+++ b/.sqlx/query-43d832a38422b97bbc6dfd3447f2985f871627c81e7a82c52c18e37a0e3c176d.json
@@ -0,0 +1,83 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT\n id AS \"id!\", thread_id AS \"thread_id!\", sender_id AS \"sender_id!\",\n triggered_by_user_id,\n content, agent_session_message_id,\n created_at AS \"created_at!\", updated_at AS \"updated_at!\",\n edited_at::timestamptz AS \"edited_at?\",\n reply_count AS \"reply_count!\", latest_reply_at AS \"latest_reply_at?\"\n FROM (\n SELECT\n r.id,\n r.thread_id,\n r.sender_id,\n r.triggered_by_user_id,\n r.content,\n r.agent_session_message_id,\n r.created_at,\n r.updated_at,\n r.edited_at,\n COUNT(*) OVER (PARTITION BY r.thread_id) AS reply_count,\n MAX(r.created_at) OVER (PARTITION BY r.thread_id)::timestamptz AS latest_reply_at,\n ROW_NUMBER() OVER (\n PARTITION BY r.thread_id\n ORDER BY r.created_at ASC, r.id ASC\n ) AS rn\n FROM comms_messages r\n WHERE r.thread_id = ANY($1) AND r.deleted_at IS NULL\n ) sub\n WHERE rn <= $2\n ORDER BY thread_id, created_at ASC, id ASC\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id!",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 1,
+ "name": "thread_id!",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 2,
+ "name": "sender_id!",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 3,
+ "name": "triggered_by_user_id",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 4,
+ "name": "content",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 5,
+ "name": "agent_session_message_id",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 6,
+ "name": "created_at!",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 7,
+ "name": "updated_at!",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 8,
+ "name": "edited_at?",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 9,
+ "name": "reply_count!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 10,
+ "name": "latest_reply_at?",
+ "type_info": "Timestamptz"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "UuidArray",
+ "Int8"
+ ]
+ },
+ "nullable": [
+ false,
+ true,
+ false,
+ true,
+ true,
+ true,
+ false,
+ false,
+ null,
+ null,
+ null
+ ]
+ },
+ "hash": "43d832a38422b97bbc6dfd3447f2985f871627c81e7a82c52c18e37a0e3c176d"
+}
diff --git a/.sqlx/query-4e6737a5a777280784037e88ccdda4e6e89dae702e5b50269db6e17265d14643.json b/.sqlx/query-4e6737a5a777280784037e88ccdda4e6e89dae702e5b50269db6e17265d14643.json
index df6dc3c2636..615cdfe072d 100644
--- a/.sqlx/query-4e6737a5a777280784037e88ccdda4e6e89dae702e5b50269db6e17265d14643.json
+++ b/.sqlx/query-4e6737a5a777280784037e88ccdda4e6e89dae702e5b50269db6e17265d14643.json
@@ -57,7 +57,7 @@
false,
true,
false,
- false,
+ true,
false,
false
]
diff --git a/.sqlx/query-506f7fbc61e5c2b0caedcc8ca1af001b1da7b88bcd175929f42b88146d4707f8.json b/.sqlx/query-506f7fbc61e5c2b0caedcc8ca1af001b1da7b88bcd175929f42b88146d4707f8.json
index b0dbe817741..c354968cf8a 100644
--- a/.sqlx/query-506f7fbc61e5c2b0caedcc8ca1af001b1da7b88bcd175929f42b88146d4707f8.json
+++ b/.sqlx/query-506f7fbc61e5c2b0caedcc8ca1af001b1da7b88bcd175929f42b88146d4707f8.json
@@ -57,7 +57,7 @@
false,
true,
false,
- false,
+ true,
false,
false
]
diff --git a/.sqlx/query-61246005cb69889a67ac46d2032f4fe9c1da7b19594f1b7dc59f2ed0a613dec1.json b/.sqlx/query-61246005cb69889a67ac46d2032f4fe9c1da7b19594f1b7dc59f2ed0a613dec1.json
index ccd0aadea58..902a065dbbd 100644
--- a/.sqlx/query-61246005cb69889a67ac46d2032f4fe9c1da7b19594f1b7dc59f2ed0a613dec1.json
+++ b/.sqlx/query-61246005cb69889a67ac46d2032f4fe9c1da7b19594f1b7dc59f2ed0a613dec1.json
@@ -62,7 +62,7 @@
false,
false,
false,
- false,
+ true,
false,
false,
true,
diff --git a/.sqlx/query-616364e2fe05cf29270eca89e09ea4c76af870f45df004ab8c5626d73a664acc.json b/.sqlx/query-616364e2fe05cf29270eca89e09ea4c76af870f45df004ab8c5626d73a664acc.json
deleted file mode 100644
index b5640f3fe9e..00000000000
--- a/.sqlx/query-616364e2fe05cf29270eca89e09ea4c76af870f45df004ab8c5626d73a664acc.json
+++ /dev/null
@@ -1,22 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "SELECT EXISTS(SELECT 1 FROM comms_channels WHERE id = $1) AS \"exists!\"",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "exists!",
- "type_info": "Bool"
- }
- ],
- "parameters": {
- "Left": [
- "Uuid"
- ]
- },
- "nullable": [
- null
- ]
- },
- "hash": "616364e2fe05cf29270eca89e09ea4c76af870f45df004ab8c5626d73a664acc"
-}
diff --git a/.sqlx/query-650316b3bc9215d0fc4937a31834dfd5098ae5230ef3ccf7cbe4b091fb995b46.json b/.sqlx/query-650316b3bc9215d0fc4937a31834dfd5098ae5230ef3ccf7cbe4b091fb995b46.json
deleted file mode 100644
index 8480d902ddb..00000000000
--- a/.sqlx/query-650316b3bc9215d0fc4937a31834dfd5098ae5230ef3ccf7cbe4b091fb995b46.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "SELECT COUNT(*) AS \"count!\" FROM comms_channels WHERE kind = 'agent'",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "count!",
- "type_info": "Int8"
- }
- ],
- "parameters": {
- "Left": []
- },
- "nullable": [
- null
- ]
- },
- "hash": "650316b3bc9215d0fc4937a31834dfd5098ae5230ef3ccf7cbe4b091fb995b46"
-}
diff --git a/.sqlx/query-d82d977779927364228d9652cd1958df1fa31ba45ef06790f540139098e3d10f.json b/.sqlx/query-68333330d48632bfe4b7c8f5fe8424623fd176727a6514b7a475d8b4df1f7003.json
similarity index 73%
rename from .sqlx/query-d82d977779927364228d9652cd1958df1fa31ba45ef06790f540139098e3d10f.json
rename to .sqlx/query-68333330d48632bfe4b7c8f5fe8424623fd176727a6514b7a475d8b4df1f7003.json
index 2a7f8ba1fac..19d231aa5bd 100644
--- a/.sqlx/query-d82d977779927364228d9652cd1958df1fa31ba45ef06790f540139098e3d10f.json
+++ b/.sqlx/query-68333330d48632bfe4b7c8f5fe8424623fd176727a6514b7a475d8b4df1f7003.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n SELECT\n id,\n channel_id,\n thread_id,\n sender_id,\n triggered_by_user_id,\n content,\n created_at,\n updated_at,\n edited_at::timestamptz AS \"edited_at?\",\n deleted_at::timestamptz AS \"deleted_at?\"\n FROM comms_messages\n WHERE id = $1 AND channel_id = $2\n ",
+ "query": "\n SELECT\n id,\n channel_id,\n thread_id,\n sender_id,\n triggered_by_user_id,\n content,\n agent_session_message_id,\n created_at,\n updated_at,\n edited_at::timestamptz AS \"edited_at?\",\n deleted_at::timestamptz AS \"deleted_at?\"\n FROM comms_messages\n WHERE id = $1 AND channel_id = $2\n ",
"describe": {
"columns": [
{
@@ -35,21 +35,26 @@
},
{
"ordinal": 6,
+ "name": "agent_session_message_id",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 7,
"name": "created_at",
"type_info": "Timestamptz"
},
{
- "ordinal": 7,
+ "ordinal": 8,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
- "ordinal": 8,
+ "ordinal": 9,
"name": "edited_at?",
"type_info": "Timestamptz"
},
{
- "ordinal": 9,
+ "ordinal": 10,
"name": "deleted_at?",
"type_info": "Timestamptz"
}
@@ -66,12 +71,13 @@
true,
false,
true,
- false,
+ true,
+ true,
false,
false,
null,
null
]
},
- "hash": "d82d977779927364228d9652cd1958df1fa31ba45ef06790f540139098e3d10f"
+ "hash": "68333330d48632bfe4b7c8f5fe8424623fd176727a6514b7a475d8b4df1f7003"
}
diff --git a/.sqlx/query-993f8bd336bc9ba45ab51edac700887af626bf74de28e395ab8a1875bc9fe9ac.json b/.sqlx/query-78bc7a6beb2f3935f8df07744673a17fb223a8163faddf8d3b12926941cba8ed.json
similarity index 58%
rename from .sqlx/query-993f8bd336bc9ba45ab51edac700887af626bf74de28e395ab8a1875bc9fe9ac.json
rename to .sqlx/query-78bc7a6beb2f3935f8df07744673a17fb223a8163faddf8d3b12926941cba8ed.json
index 44028b170c1..64cf68908aa 100644
--- a/.sqlx/query-993f8bd336bc9ba45ab51edac700887af626bf74de28e395ab8a1875bc9fe9ac.json
+++ b/.sqlx/query-78bc7a6beb2f3935f8df07744673a17fb223a8163faddf8d3b12926941cba8ed.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n SELECT\n m.id AS \"message_id!\",\n m.thread_id,\n m.sender_id AS \"sender_id!\",\n m.content AS \"content!\",\n m.created_at AS \"created_at!\",\n m.updated_at AS \"updated_at!\",\n m.deleted_at::timestamptz AS \"deleted_at?\",\n COALESCE(\n ARRAY(\n SELECT entity_type || ':' || entity_id\n FROM comms_entity_mentions em\n WHERE em.source_entity_type = 'message'\n AND em.source_entity_id = m.id::text\n ),\n '{}'::text[]\n ) AS \"mentions!\"\n FROM comms_messages m\n WHERE m.channel_id = $1\n AND m.deleted_at IS NULL\n ORDER BY m.created_at DESC\n LIMIT $2\n ",
+ "query": "\n SELECT\n m.id AS \"message_id!\",\n m.thread_id,\n m.sender_id AS \"sender_id!\",\n m.content,\n m.created_at AS \"created_at!\",\n m.updated_at AS \"updated_at!\",\n m.deleted_at::timestamptz AS \"deleted_at?\",\n COALESCE(\n ARRAY(\n SELECT entity_type || ':' || entity_id\n FROM comms_entity_mentions em\n WHERE em.source_entity_type = 'message'\n AND em.source_entity_id = m.id::text\n ),\n '{}'::text[]\n ) AS \"mentions!\"\n FROM comms_messages m\n WHERE m.channel_id = $1\n AND m.deleted_at IS NULL\n ORDER BY m.created_at DESC\n LIMIT $2\n ",
"describe": {
"columns": [
{
@@ -20,7 +20,7 @@
},
{
"ordinal": 3,
- "name": "content!",
+ "name": "content",
"type_info": "Text"
},
{
@@ -54,12 +54,12 @@
false,
true,
false,
- false,
+ true,
false,
false,
null,
null
]
},
- "hash": "993f8bd336bc9ba45ab51edac700887af626bf74de28e395ab8a1875bc9fe9ac"
+ "hash": "78bc7a6beb2f3935f8df07744673a17fb223a8163faddf8d3b12926941cba8ed"
}
diff --git a/.sqlx/query-725e8efb816dd4f03c34db74c141dc0929de9df06ec14e4c7eb93409a0bd0de8.json b/.sqlx/query-7dce78a74f433b12441f5f36243273e692b94d2f3df42523f6146f995d767212.json
similarity index 70%
rename from .sqlx/query-725e8efb816dd4f03c34db74c141dc0929de9df06ec14e4c7eb93409a0bd0de8.json
rename to .sqlx/query-7dce78a74f433b12441f5f36243273e692b94d2f3df42523f6146f995d767212.json
index ec2418ff4f5..f08143a2527 100644
--- a/.sqlx/query-725e8efb816dd4f03c34db74c141dc0929de9df06ec14e4c7eb93409a0bd0de8.json
+++ b/.sqlx/query-7dce78a74f433b12441f5f36243273e692b94d2f3df42523f6146f995d767212.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n SELECT\n id,\n channel_id,\n thread_id,\n sender_id,\n triggered_by_user_id,\n content,\n created_at,\n updated_at,\n edited_at::timestamptz AS \"edited_at?\",\n deleted_at::timestamptz AS \"deleted_at?\"\n FROM comms_messages\n WHERE channel_id = $1\n AND (created_at, id) < ($2, $3)\n ORDER BY created_at DESC, id DESC\n LIMIT $4\n ",
+ "query": "\n SELECT\n id,\n channel_id,\n thread_id,\n sender_id,\n triggered_by_user_id,\n content,\n agent_session_message_id,\n created_at,\n updated_at,\n edited_at::timestamptz AS \"edited_at?\",\n deleted_at::timestamptz AS \"deleted_at?\"\n FROM comms_messages\n WHERE channel_id = $1\n AND (created_at, id) > ($2, $3)\n ORDER BY created_at ASC, id ASC\n LIMIT $4\n ",
"describe": {
"columns": [
{
@@ -35,21 +35,26 @@
},
{
"ordinal": 6,
+ "name": "agent_session_message_id",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 7,
"name": "created_at",
"type_info": "Timestamptz"
},
{
- "ordinal": 7,
+ "ordinal": 8,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
- "ordinal": 8,
+ "ordinal": 9,
"name": "edited_at?",
"type_info": "Timestamptz"
},
{
- "ordinal": 9,
+ "ordinal": 10,
"name": "deleted_at?",
"type_info": "Timestamptz"
}
@@ -68,12 +73,13 @@
true,
false,
true,
- false,
+ true,
+ true,
false,
false,
null,
null
]
},
- "hash": "725e8efb816dd4f03c34db74c141dc0929de9df06ec14e4c7eb93409a0bd0de8"
+ "hash": "7dce78a74f433b12441f5f36243273e692b94d2f3df42523f6146f995d767212"
}
diff --git a/.sqlx/query-811160740482a042237f6ffc433290e0e89ba9521fbba575634fd92a39433271.json b/.sqlx/query-811160740482a042237f6ffc433290e0e89ba9521fbba575634fd92a39433271.json
index 90d232e808a..6dcd2d02a93 100644
--- a/.sqlx/query-811160740482a042237f6ffc433290e0e89ba9521fbba575634fd92a39433271.json
+++ b/.sqlx/query-811160740482a042237f6ffc433290e0e89ba9521fbba575634fd92a39433271.json
@@ -65,7 +65,7 @@
false,
false,
true,
- false,
+ true,
false,
false,
true,
diff --git a/.sqlx/query-908627bdd27f344ce26c501bb7431b621173c04bfda9678021360817c3f25103.json b/.sqlx/query-908627bdd27f344ce26c501bb7431b621173c04bfda9678021360817c3f25103.json
index ad59b616b1f..9f77c02faf4 100644
--- a/.sqlx/query-908627bdd27f344ce26c501bb7431b621173c04bfda9678021360817c3f25103.json
+++ b/.sqlx/query-908627bdd27f344ce26c501bb7431b621173c04bfda9678021360817c3f25103.json
@@ -85,7 +85,7 @@
false,
true,
false,
- false,
+ true,
false,
false,
null
diff --git a/.sqlx/query-91e974129e2c983925d29dabf73c8ae86c6f090cbfd28f22cd952756950a94db.json b/.sqlx/query-91e974129e2c983925d29dabf73c8ae86c6f090cbfd28f22cd952756950a94db.json
index 806760a2c8b..c6149c32b8d 100644
--- a/.sqlx/query-91e974129e2c983925d29dabf73c8ae86c6f090cbfd28f22cd952756950a94db.json
+++ b/.sqlx/query-91e974129e2c983925d29dabf73c8ae86c6f090cbfd28f22cd952756950a94db.json
@@ -62,7 +62,7 @@
false,
false,
false,
- false,
+ true,
false,
false,
true,
diff --git a/.sqlx/query-96f2482e829992f905137782aa37d8559171dacf2675417c2318cd4dd468ce94.json b/.sqlx/query-96f2482e829992f905137782aa37d8559171dacf2675417c2318cd4dd468ce94.json
deleted file mode 100644
index 9a07b1debb9..00000000000
--- a/.sqlx/query-96f2482e829992f905137782aa37d8559171dacf2675417c2318cd4dd468ce94.json
+++ /dev/null
@@ -1,82 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "\n SELECT\n m.id,\n m.channel_id,\n m.sender_id,\n m.triggered_by_user_id,\n m.content,\n m.created_at,\n m.updated_at,\n m.edited_at::timestamptz AS \"edited_at?\",\n m.deleted_at::timestamptz AS \"deleted_at?\"\n FROM comms_messages m\n WHERE m.channel_id = $1\n AND m.thread_id IS NULL\n AND (m.deleted_at IS NULL OR EXISTS (\n SELECT 1 FROM comms_messages r\n WHERE r.thread_id = m.id AND r.deleted_at IS NULL\n ))\n AND ($2::timestamptz IS NOT NULL AND (m.created_at, m.id) > ($2, $3))\n AND ($5::uuid[] IS NULL OR m.id = ANY($5))\n AND ($6::timestamptz IS NULL OR m.created_at >= $6)\n AND ($7::timestamptz IS NULL OR m.created_at < $7)\n AND (\n ($8::timestamptz IS NULL AND $9::timestamptz IS NULL)\n OR (\n ($8::timestamptz IS NULL OR m.created_at >= $8)\n AND ($9::timestamptz IS NULL OR m.created_at < $9)\n )\n OR EXISTS (\n SELECT 1 FROM comms_messages r\n WHERE r.thread_id = m.id\n AND r.deleted_at IS NULL\n AND ($8::timestamptz IS NULL OR r.created_at >= $8)\n AND ($9::timestamptz IS NULL OR r.created_at < $9)\n )\n )\n AND ($10::bool = FALSE OR (\n ($11::bool IS NULL OR EXISTS (\n SELECT 1\n FROM notification n\n JOIN user_notification un ON un.notification_id = n.id\n JOIN comms_messages msg ON msg.id = (n.metadata->>'messageId')::uuid\n WHERE un.user_id = $13::text\n AND un.deleted_at IS NULL\n AND un.done = $11\n AND n.event_item_type = 'channel'\n AND n.event_item_id = $1::uuid::text\n AND n.metadata->>'messageId' IS NOT NULL\n AND msg.channel_id = $1\n AND msg.deleted_at IS NULL\n AND COALESCE(msg.thread_id, msg.id) = m.id\n ))\n AND ($12::bool IS NULL OR EXISTS (\n SELECT 1\n FROM notification n\n JOIN user_notification un ON un.notification_id = n.id\n JOIN comms_messages msg ON msg.id = (n.metadata->>'messageId')::uuid\n WHERE un.user_id = $13::text\n AND un.deleted_at IS NULL\n AND (un.seen_at IS NOT NULL) = $12\n AND n.event_item_type = 'channel'\n AND n.event_item_id = $1::uuid::text\n AND n.metadata->>'messageId' IS NOT NULL\n AND msg.channel_id = $1\n AND msg.deleted_at IS NULL\n AND COALESCE(msg.thread_id, msg.id) = m.id\n ))\n ))\n ORDER BY m.created_at ASC, m.id ASC\n LIMIT $4\n ",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "id",
- "type_info": "Uuid"
- },
- {
- "ordinal": 1,
- "name": "channel_id",
- "type_info": "Uuid"
- },
- {
- "ordinal": 2,
- "name": "sender_id",
- "type_info": "Text"
- },
- {
- "ordinal": 3,
- "name": "triggered_by_user_id",
- "type_info": "Text"
- },
- {
- "ordinal": 4,
- "name": "content",
- "type_info": "Text"
- },
- {
- "ordinal": 5,
- "name": "created_at",
- "type_info": "Timestamptz"
- },
- {
- "ordinal": 6,
- "name": "updated_at",
- "type_info": "Timestamptz"
- },
- {
- "ordinal": 7,
- "name": "edited_at?",
- "type_info": "Timestamptz"
- },
- {
- "ordinal": 8,
- "name": "deleted_at?",
- "type_info": "Timestamptz"
- }
- ],
- "parameters": {
- "Left": [
- "Uuid",
- "Timestamptz",
- "Uuid",
- "Int8",
- "UuidArray",
- "Timestamptz",
- "Timestamptz",
- "Timestamptz",
- "Timestamptz",
- "Bool",
- "Bool",
- "Bool",
- "Text"
- ]
- },
- "nullable": [
- false,
- false,
- false,
- true,
- false,
- false,
- false,
- null,
- null
- ]
- },
- "hash": "96f2482e829992f905137782aa37d8559171dacf2675417c2318cd4dd468ce94"
-}
diff --git a/.sqlx/query-7ee9ba5adfac304c7ac930943538ecca7428b1054d451fb2f8973cf9c11caf45.json b/.sqlx/query-b1e404833387b705eb009bf8b7e4ee4ce16c75b9cfd1bca5094a99a9a8462b23.json
similarity index 71%
rename from .sqlx/query-7ee9ba5adfac304c7ac930943538ecca7428b1054d451fb2f8973cf9c11caf45.json
rename to .sqlx/query-b1e404833387b705eb009bf8b7e4ee4ce16c75b9cfd1bca5094a99a9a8462b23.json
index fbdbdf128a1..71f8b885303 100644
--- a/.sqlx/query-7ee9ba5adfac304c7ac930943538ecca7428b1054d451fb2f8973cf9c11caf45.json
+++ b/.sqlx/query-b1e404833387b705eb009bf8b7e4ee4ce16c75b9cfd1bca5094a99a9a8462b23.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n WITH input_ids AS (\n SELECT UNNEST($1::uuid[]) AS channel_id\n )\n SELECT\n i.channel_id AS \"channel_id!\",\n l.message_id AS \"l_message_id?: uuid::Uuid\",\n l.thread_id AS \"l_thread_id?: uuid::Uuid\",\n l.sender_id AS \"l_sender_id?: String\",\n l.content AS \"l_content?: String\",\n l.created_at AS \"l_created_at?: chrono::DateTime\",\n l.updated_at AS \"l_updated_at?: chrono::DateTime\",\n l.deleted_at AS \"l_deleted_at?: chrono::DateTime\",\n l.mentions AS \"l_mentions?: Vec\",\n n.message_id AS \"n_message_id?: uuid::Uuid\",\n n.thread_id AS \"n_thread_id?: uuid::Uuid\",\n n.sender_id AS \"n_sender_id?: String\",\n n.content AS \"n_content?: String\",\n n.created_at AS \"n_created_at?: chrono::DateTime\",\n n.updated_at AS \"n_updated_at?: chrono::DateTime\",\n n.deleted_at AS \"n_deleted_at?: chrono::DateTime\",\n n.mentions AS \"n_mentions?: Vec\"\n FROM input_ids i\n LEFT JOIN LATERAL (\n SELECT\n m.id AS message_id,\n m.thread_id,\n m.sender_id,\n m.content,\n m.created_at,\n m.updated_at,\n m.deleted_at::timestamptz AS deleted_at,\n COALESCE(\n ARRAY(\n SELECT entity_type || ':' || entity_id\n FROM comms_entity_mentions em\n WHERE em.source_entity_type = 'message'\n AND em.source_entity_id = m.id::text\n ),\n '{}'::text[]\n ) AS mentions\n FROM comms_messages m\n WHERE m.channel_id = i.channel_id\n AND m.deleted_at IS NULL\n ORDER BY m.created_at DESC\n LIMIT 1\n ) l ON TRUE\n LEFT JOIN LATERAL (\n SELECT\n m.id AS message_id,\n m.thread_id,\n m.sender_id,\n m.content,\n m.created_at,\n m.updated_at,\n m.deleted_at::timestamptz AS deleted_at,\n COALESCE(\n ARRAY(\n SELECT entity_type || ':' || entity_id\n FROM comms_entity_mentions em\n WHERE em.source_entity_type = 'message'\n AND em.source_entity_id = m.id::text\n ),\n '{}'::text[]\n ) AS mentions\n FROM comms_messages m\n WHERE m.channel_id = i.channel_id\n AND m.deleted_at IS NULL\n AND m.thread_id IS NULL\n ORDER BY m.created_at DESC\n LIMIT 1\n ) n ON TRUE\n ",
+ "query": "\n WITH input_ids AS (\n SELECT UNNEST($1::uuid[]) AS channel_id\n )\n SELECT\n i.channel_id AS \"channel_id!\",\n l.message_id AS \"l_message_id?: uuid::Uuid\",\n l.thread_id AS \"l_thread_id?: uuid::Uuid\",\n l.sender_id AS \"l_sender_id?: String\",\n l.content AS \"l_content?: String\",\n l.created_at AS \"l_created_at?: chrono::DateTime\",\n l.updated_at AS \"l_updated_at?: chrono::DateTime\",\n l.deleted_at AS \"l_deleted_at?: chrono::DateTime\",\n l.mentions AS \"l_mentions?: Vec\",\n n.message_id AS \"n_message_id?: uuid::Uuid\",\n n.thread_id AS \"n_thread_id?: uuid::Uuid\",\n n.sender_id AS \"n_sender_id?: String\",\n n.content AS \"n_content?: String\",\n n.created_at AS \"n_created_at?: chrono::DateTime\",\n n.updated_at AS \"n_updated_at?: chrono::DateTime\",\n n.deleted_at AS \"n_deleted_at?: chrono::DateTime\",\n n.mentions AS \"n_mentions?: Vec\"\n FROM input_ids i\n LEFT JOIN LATERAL (\n SELECT\n m.id AS message_id,\n m.thread_id,\n m.sender_id,\n m.content,\n m.agent_session_message_id,\n m.created_at,\n m.updated_at,\n m.deleted_at::timestamptz AS deleted_at,\n COALESCE(\n ARRAY(\n SELECT entity_type || ':' || entity_id\n FROM comms_entity_mentions em\n WHERE em.source_entity_type = 'message'\n AND em.source_entity_id = m.id::text\n ),\n '{}'::text[]\n ) AS mentions\n FROM comms_messages m\n WHERE m.channel_id = i.channel_id\n AND m.deleted_at IS NULL\n ORDER BY m.created_at DESC\n LIMIT 1\n ) l ON TRUE\n LEFT JOIN LATERAL (\n SELECT\n m.id AS message_id,\n m.thread_id,\n m.sender_id,\n m.content,\n m.agent_session_message_id,\n m.created_at,\n m.updated_at,\n m.deleted_at::timestamptz AS deleted_at,\n COALESCE(\n ARRAY(\n SELECT entity_type || ':' || entity_id\n FROM comms_entity_mentions em\n WHERE em.source_entity_type = 'message'\n AND em.source_entity_id = m.id::text\n ),\n '{}'::text[]\n ) AS mentions\n FROM comms_messages m\n WHERE m.channel_id = i.channel_id\n AND m.deleted_at IS NULL\n AND m.thread_id IS NULL\n ORDER BY m.created_at DESC\n LIMIT 1\n ) n ON TRUE\n ",
"describe": {
"columns": [
{
@@ -114,5 +114,5 @@
true
]
},
- "hash": "7ee9ba5adfac304c7ac930943538ecca7428b1054d451fb2f8973cf9c11caf45"
+ "hash": "b1e404833387b705eb009bf8b7e4ee4ce16c75b9cfd1bca5094a99a9a8462b23"
}
diff --git a/.sqlx/query-ec3636e62ff865a5c6808e8f87d65730837b043b9cb69ce7075c431cee32b58b.json b/.sqlx/query-bfd39d96197c12a509926cb3af2fbb6dbca234573258ab89a71ec2d9b2aba6f5.json
similarity index 61%
rename from .sqlx/query-ec3636e62ff865a5c6808e8f87d65730837b043b9cb69ce7075c431cee32b58b.json
rename to .sqlx/query-bfd39d96197c12a509926cb3af2fbb6dbca234573258ab89a71ec2d9b2aba6f5.json
index 72ef23f4f82..bb3c6d1edf4 100644
--- a/.sqlx/query-ec3636e62ff865a5c6808e8f87d65730837b043b9cb69ce7075c431cee32b58b.json
+++ b/.sqlx/query-bfd39d96197c12a509926cb3af2fbb6dbca234573258ab89a71ec2d9b2aba6f5.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n SELECT\n m.id,\n m.channel_id,\n m.sender_id,\n m.triggered_by_user_id,\n m.content,\n m.created_at,\n m.updated_at,\n m.edited_at::timestamptz AS \"edited_at?\",\n m.deleted_at::timestamptz AS \"deleted_at?\"\n FROM comms_messages m\n WHERE m.channel_id = $1\n AND m.thread_id IS NULL\n AND (m.deleted_at IS NULL OR EXISTS (\n SELECT 1 FROM comms_messages r\n WHERE r.thread_id = m.id AND r.deleted_at IS NULL\n ))\n AND (m.created_at, m.id) < ($2, $3)\n ORDER BY m.created_at DESC, m.id DESC\n LIMIT $4\n ",
+ "query": "\n SELECT\n m.id,\n m.channel_id,\n m.sender_id,\n m.triggered_by_user_id,\n m.content,\n m.agent_session_message_id,\n m.created_at,\n m.updated_at,\n m.edited_at::timestamptz AS \"edited_at?\",\n m.deleted_at::timestamptz AS \"deleted_at?\"\n FROM comms_messages m\n WHERE m.channel_id = $1\n AND m.thread_id IS NULL\n AND (m.deleted_at IS NULL OR EXISTS (\n SELECT 1 FROM comms_messages r\n WHERE r.thread_id = m.id AND r.deleted_at IS NULL\n ))\n AND (m.created_at, m.id) > ($2, $3)\n ORDER BY m.created_at ASC, m.id ASC\n LIMIT $4\n ",
"describe": {
"columns": [
{
@@ -30,21 +30,26 @@
},
{
"ordinal": 5,
+ "name": "agent_session_message_id",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 6,
"name": "created_at",
"type_info": "Timestamptz"
},
{
- "ordinal": 6,
+ "ordinal": 7,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
- "ordinal": 7,
+ "ordinal": 8,
"name": "edited_at?",
"type_info": "Timestamptz"
},
{
- "ordinal": 8,
+ "ordinal": 9,
"name": "deleted_at?",
"type_info": "Timestamptz"
}
@@ -62,12 +67,13 @@
false,
false,
true,
- false,
+ true,
+ true,
false,
false,
null,
null
]
},
- "hash": "ec3636e62ff865a5c6808e8f87d65730837b043b9cb69ce7075c431cee32b58b"
+ "hash": "bfd39d96197c12a509926cb3af2fbb6dbca234573258ab89a71ec2d9b2aba6f5"
}
diff --git a/.sqlx/query-c3a21ba551a400f7dbdd9f80f7f3a857d2ea88043f3557137fb2718e64661ce0.json b/.sqlx/query-c3a21ba551a400f7dbdd9f80f7f3a857d2ea88043f3557137fb2718e64661ce0.json
deleted file mode 100644
index 93b2477aa8e..00000000000
--- a/.sqlx/query-c3a21ba551a400f7dbdd9f80f7f3a857d2ea88043f3557137fb2718e64661ce0.json
+++ /dev/null
@@ -1,82 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "\n SELECT\n m.id,\n m.channel_id,\n m.sender_id,\n m.triggered_by_user_id,\n m.content,\n m.created_at,\n m.updated_at,\n m.edited_at::timestamptz AS \"edited_at?\",\n m.deleted_at::timestamptz AS \"deleted_at?\"\n FROM comms_messages m\n WHERE m.channel_id = $1\n AND m.thread_id IS NULL\n AND (m.deleted_at IS NULL OR EXISTS (\n SELECT 1 FROM comms_messages r\n WHERE r.thread_id = m.id AND r.deleted_at IS NULL\n ))\n AND ($2::timestamptz IS NULL OR (m.created_at, m.id) < ($2, $3))\n AND ($5::uuid[] IS NULL OR m.id = ANY($5))\n AND ($6::timestamptz IS NULL OR m.created_at >= $6)\n AND ($7::timestamptz IS NULL OR m.created_at < $7)\n AND (\n ($8::timestamptz IS NULL AND $9::timestamptz IS NULL)\n OR (\n ($8::timestamptz IS NULL OR m.created_at >= $8)\n AND ($9::timestamptz IS NULL OR m.created_at < $9)\n )\n OR EXISTS (\n SELECT 1 FROM comms_messages r\n WHERE r.thread_id = m.id\n AND r.deleted_at IS NULL\n AND ($8::timestamptz IS NULL OR r.created_at >= $8)\n AND ($9::timestamptz IS NULL OR r.created_at < $9)\n )\n )\n AND ($10::bool = FALSE OR (\n ($11::bool IS NULL OR EXISTS (\n SELECT 1\n FROM notification n\n JOIN user_notification un ON un.notification_id = n.id\n JOIN comms_messages msg ON msg.id = (n.metadata->>'messageId')::uuid\n WHERE un.user_id = $13::text\n AND un.deleted_at IS NULL\n AND un.done = $11\n AND n.event_item_type = 'channel'\n AND n.event_item_id = $1::uuid::text\n AND n.metadata->>'messageId' IS NOT NULL\n AND msg.channel_id = $1\n AND msg.deleted_at IS NULL\n AND COALESCE(msg.thread_id, msg.id) = m.id\n ))\n AND ($12::bool IS NULL OR EXISTS (\n SELECT 1\n FROM notification n\n JOIN user_notification un ON un.notification_id = n.id\n JOIN comms_messages msg ON msg.id = (n.metadata->>'messageId')::uuid\n WHERE un.user_id = $13::text\n AND un.deleted_at IS NULL\n AND (un.seen_at IS NOT NULL) = $12\n AND n.event_item_type = 'channel'\n AND n.event_item_id = $1::uuid::text\n AND n.metadata->>'messageId' IS NOT NULL\n AND msg.channel_id = $1\n AND msg.deleted_at IS NULL\n AND COALESCE(msg.thread_id, msg.id) = m.id\n ))\n ))\n ORDER BY m.created_at DESC, m.id DESC\n LIMIT $4\n ",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "id",
- "type_info": "Uuid"
- },
- {
- "ordinal": 1,
- "name": "channel_id",
- "type_info": "Uuid"
- },
- {
- "ordinal": 2,
- "name": "sender_id",
- "type_info": "Text"
- },
- {
- "ordinal": 3,
- "name": "triggered_by_user_id",
- "type_info": "Text"
- },
- {
- "ordinal": 4,
- "name": "content",
- "type_info": "Text"
- },
- {
- "ordinal": 5,
- "name": "created_at",
- "type_info": "Timestamptz"
- },
- {
- "ordinal": 6,
- "name": "updated_at",
- "type_info": "Timestamptz"
- },
- {
- "ordinal": 7,
- "name": "edited_at?",
- "type_info": "Timestamptz"
- },
- {
- "ordinal": 8,
- "name": "deleted_at?",
- "type_info": "Timestamptz"
- }
- ],
- "parameters": {
- "Left": [
- "Uuid",
- "Timestamptz",
- "Uuid",
- "Int8",
- "UuidArray",
- "Timestamptz",
- "Timestamptz",
- "Timestamptz",
- "Timestamptz",
- "Bool",
- "Bool",
- "Bool",
- "Text"
- ]
- },
- "nullable": [
- false,
- false,
- false,
- true,
- false,
- false,
- false,
- null,
- null
- ]
- },
- "hash": "c3a21ba551a400f7dbdd9f80f7f3a857d2ea88043f3557137fb2718e64661ce0"
-}
diff --git a/.sqlx/query-246d88f16e6bac8064be41148528e0545be33e3c43a3dc5193ab12220f8d8a7c.json b/.sqlx/query-c81dabe48269c198cfebc1287d92d94ba013c0f54f111ed98b3f70f8fa4e89cd.json
similarity index 69%
rename from .sqlx/query-246d88f16e6bac8064be41148528e0545be33e3c43a3dc5193ab12220f8d8a7c.json
rename to .sqlx/query-c81dabe48269c198cfebc1287d92d94ba013c0f54f111ed98b3f70f8fa4e89cd.json
index 5d095581d7a..2b366f92a6d 100644
--- a/.sqlx/query-246d88f16e6bac8064be41148528e0545be33e3c43a3dc5193ab12220f8d8a7c.json
+++ b/.sqlx/query-c81dabe48269c198cfebc1287d92d94ba013c0f54f111ed98b3f70f8fa4e89cd.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n SELECT\n id,\n thread_id AS \"thread_id!\",\n sender_id,\n triggered_by_user_id,\n content,\n created_at,\n updated_at,\n edited_at::timestamptz AS \"edited_at?\"\n FROM comms_messages\n WHERE thread_id = $1\n AND deleted_at IS NULL\n ORDER BY created_at ASC, id ASC\n ",
+ "query": "\n SELECT\n id,\n thread_id AS \"thread_id!\",\n sender_id,\n triggered_by_user_id,\n content,\n agent_session_message_id,\n created_at,\n updated_at,\n edited_at::timestamptz AS \"edited_at?\"\n FROM comms_messages\n WHERE thread_id = $1\n AND deleted_at IS NULL\n ORDER BY created_at ASC, id ASC\n ",
"describe": {
"columns": [
{
@@ -30,16 +30,21 @@
},
{
"ordinal": 5,
+ "name": "agent_session_message_id",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 6,
"name": "created_at",
"type_info": "Timestamptz"
},
{
- "ordinal": 6,
+ "ordinal": 7,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
- "ordinal": 7,
+ "ordinal": 8,
"name": "edited_at?",
"type_info": "Timestamptz"
}
@@ -54,11 +59,12 @@
true,
false,
true,
- false,
+ true,
+ true,
false,
false,
null
]
},
- "hash": "246d88f16e6bac8064be41148528e0545be33e3c43a3dc5193ab12220f8d8a7c"
+ "hash": "c81dabe48269c198cfebc1287d92d94ba013c0f54f111ed98b3f70f8fa4e89cd"
}
diff --git a/.sqlx/query-caab9ca115f32ea6049a55e8dcff3365a7943c2c34433980ccc94817f219b3ee.json b/.sqlx/query-caab9ca115f32ea6049a55e8dcff3365a7943c2c34433980ccc94817f219b3ee.json
deleted file mode 100644
index 2dc1ca0606f..00000000000
--- a/.sqlx/query-caab9ca115f32ea6049a55e8dcff3365a7943c2c34433980ccc94817f219b3ee.json
+++ /dev/null
@@ -1,28 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "SELECT kind, owner_id FROM comms_channels WHERE id = $1",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "kind",
- "type_info": "Text"
- },
- {
- "ordinal": 1,
- "name": "owner_id",
- "type_info": "Text"
- }
- ],
- "parameters": {
- "Left": [
- "Uuid"
- ]
- },
- "nullable": [
- false,
- false
- ]
- },
- "hash": "caab9ca115f32ea6049a55e8dcff3365a7943c2c34433980ccc94817f219b3ee"
-}
diff --git a/.sqlx/query-fbee16f91442381c51b85d5e77042ba3787eec094e62746727249f3055ec2dbb.json b/.sqlx/query-cfd656f6388b4aded4610eb39cbadcd01ea358e68c65fbf08888c1b3cd1fb907.json
similarity index 65%
rename from .sqlx/query-fbee16f91442381c51b85d5e77042ba3787eec094e62746727249f3055ec2dbb.json
rename to .sqlx/query-cfd656f6388b4aded4610eb39cbadcd01ea358e68c65fbf08888c1b3cd1fb907.json
index b1688e3f54a..15e0757827b 100644
--- a/.sqlx/query-fbee16f91442381c51b85d5e77042ba3787eec094e62746727249f3055ec2dbb.json
+++ b/.sqlx/query-cfd656f6388b4aded4610eb39cbadcd01ea358e68c65fbf08888c1b3cd1fb907.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n SELECT\n m.id,\n m.channel_id,\n m.sender_id,\n m.triggered_by_user_id,\n m.content,\n m.created_at,\n m.updated_at,\n m.edited_at::timestamptz AS \"edited_at?\",\n m.deleted_at::timestamptz AS \"deleted_at?\"\n FROM comms_messages m\n WHERE m.id = COALESCE(\n (SELECT thread_id FROM comms_messages WHERE id = $1 AND channel_id = $2),\n $1\n )\n AND m.channel_id = $2\n AND m.thread_id IS NULL\n ",
+ "query": "\n SELECT\n m.id,\n m.channel_id,\n m.sender_id,\n m.triggered_by_user_id,\n m.content,\n m.agent_session_message_id,\n m.created_at,\n m.updated_at,\n m.edited_at::timestamptz AS \"edited_at?\",\n m.deleted_at::timestamptz AS \"deleted_at?\"\n FROM comms_messages m\n WHERE m.id = COALESCE(\n (SELECT thread_id FROM comms_messages WHERE id = $1 AND channel_id = $2),\n $1\n )\n AND m.channel_id = $2\n AND m.thread_id IS NULL\n ",
"describe": {
"columns": [
{
@@ -30,21 +30,26 @@
},
{
"ordinal": 5,
+ "name": "agent_session_message_id",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 6,
"name": "created_at",
"type_info": "Timestamptz"
},
{
- "ordinal": 6,
+ "ordinal": 7,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
- "ordinal": 7,
+ "ordinal": 8,
"name": "edited_at?",
"type_info": "Timestamptz"
},
{
- "ordinal": 8,
+ "ordinal": 9,
"name": "deleted_at?",
"type_info": "Timestamptz"
}
@@ -60,12 +65,13 @@
false,
false,
true,
- false,
+ true,
+ true,
false,
false,
null,
null
]
},
- "hash": "fbee16f91442381c51b85d5e77042ba3787eec094e62746727249f3055ec2dbb"
+ "hash": "cfd656f6388b4aded4610eb39cbadcd01ea358e68c65fbf08888c1b3cd1fb907"
}
diff --git a/.sqlx/query-d0b7d158347cac8d4dfc1ca77105213bcc7843c5c967093ab561405411b71d53.json b/.sqlx/query-d0b7d158347cac8d4dfc1ca77105213bcc7843c5c967093ab561405411b71d53.json
deleted file mode 100644
index f4ff45f2643..00000000000
--- a/.sqlx/query-d0b7d158347cac8d4dfc1ca77105213bcc7843c5c967093ab561405411b71d53.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "INSERT INTO comms_messages (id, channel_id, sender_id, content) VALUES ($1, $2, $3, '')",
- "describe": {
- "columns": [],
- "parameters": {
- "Left": [
- "Uuid",
- "Uuid",
- "Text"
- ]
- },
- "nullable": []
- },
- "hash": "d0b7d158347cac8d4dfc1ca77105213bcc7843c5c967093ab561405411b71d53"
-}
diff --git a/.sqlx/query-d4e902b7511e62d58cc34fe9fede10559aba3d70c5bb25b2fe7edf1a8986fa06.json b/.sqlx/query-d4e902b7511e62d58cc34fe9fede10559aba3d70c5bb25b2fe7edf1a8986fa06.json
new file mode 100644
index 00000000000..fb2fca2cd27
--- /dev/null
+++ b/.sqlx/query-d4e902b7511e62d58cc34fe9fede10559aba3d70c5bb25b2fe7edf1a8986fa06.json
@@ -0,0 +1,88 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT\n m.id,\n m.channel_id,\n m.sender_id,\n m.triggered_by_user_id,\n m.content,\n m.agent_session_message_id,\n m.created_at,\n m.updated_at,\n m.edited_at::timestamptz AS \"edited_at?\",\n m.deleted_at::timestamptz AS \"deleted_at?\"\n FROM comms_messages m\n WHERE m.channel_id = $1\n AND m.thread_id IS NULL\n AND (m.deleted_at IS NULL OR EXISTS (\n SELECT 1 FROM comms_messages r\n WHERE r.thread_id = m.id AND r.deleted_at IS NULL\n ))\n AND ($2::timestamptz IS NULL OR (m.created_at, m.id) < ($2, $3))\n AND ($5::uuid[] IS NULL OR m.id = ANY($5))\n AND ($6::timestamptz IS NULL OR m.created_at >= $6)\n AND ($7::timestamptz IS NULL OR m.created_at < $7)\n AND (\n ($8::timestamptz IS NULL AND $9::timestamptz IS NULL)\n OR (\n ($8::timestamptz IS NULL OR m.created_at >= $8)\n AND ($9::timestamptz IS NULL OR m.created_at < $9)\n )\n OR EXISTS (\n SELECT 1 FROM comms_messages r\n WHERE r.thread_id = m.id\n AND r.deleted_at IS NULL\n AND ($8::timestamptz IS NULL OR r.created_at >= $8)\n AND ($9::timestamptz IS NULL OR r.created_at < $9)\n )\n )\n AND ($10::bool = FALSE OR (\n ($11::bool IS NULL OR EXISTS (\n SELECT 1\n FROM notification n\n JOIN user_notification un ON un.notification_id = n.id\n JOIN comms_messages msg ON msg.id = (n.metadata->>'messageId')::uuid\n WHERE un.user_id = $13::text\n AND un.deleted_at IS NULL\n AND un.done = $11\n AND n.event_item_type = 'channel'\n AND n.event_item_id = $1::uuid::text\n AND n.metadata->>'messageId' IS NOT NULL\n AND msg.channel_id = $1\n AND msg.deleted_at IS NULL\n AND COALESCE(msg.thread_id, msg.id) = m.id\n ))\n AND ($12::bool IS NULL OR EXISTS (\n SELECT 1\n FROM notification n\n JOIN user_notification un ON un.notification_id = n.id\n JOIN comms_messages msg ON msg.id = (n.metadata->>'messageId')::uuid\n WHERE un.user_id = $13::text\n AND un.deleted_at IS NULL\n AND (un.seen_at IS NOT NULL) = $12\n AND n.event_item_type = 'channel'\n AND n.event_item_id = $1::uuid::text\n AND n.metadata->>'messageId' IS NOT NULL\n AND msg.channel_id = $1\n AND msg.deleted_at IS NULL\n AND COALESCE(msg.thread_id, msg.id) = m.id\n ))\n ))\n ORDER BY m.created_at DESC, m.id DESC\n LIMIT $4\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 1,
+ "name": "channel_id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 2,
+ "name": "sender_id",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 3,
+ "name": "triggered_by_user_id",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 4,
+ "name": "content",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 5,
+ "name": "agent_session_message_id",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 6,
+ "name": "created_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 7,
+ "name": "updated_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 8,
+ "name": "edited_at?",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 9,
+ "name": "deleted_at?",
+ "type_info": "Timestamptz"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Uuid",
+ "Timestamptz",
+ "Uuid",
+ "Int8",
+ "UuidArray",
+ "Timestamptz",
+ "Timestamptz",
+ "Timestamptz",
+ "Timestamptz",
+ "Bool",
+ "Bool",
+ "Bool",
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ true,
+ true,
+ true,
+ false,
+ false,
+ null,
+ null
+ ]
+ },
+ "hash": "d4e902b7511e62d58cc34fe9fede10559aba3d70c5bb25b2fe7edf1a8986fa06"
+}
diff --git a/.sqlx/query-d786a1b974bb723a269cb31b36190910f5be0d1084e23ffe8e56c2f20015e61d.json b/.sqlx/query-d786a1b974bb723a269cb31b36190910f5be0d1084e23ffe8e56c2f20015e61d.json
deleted file mode 100644
index 5e9b58c4593..00000000000
--- a/.sqlx/query-d786a1b974bb723a269cb31b36190910f5be0d1084e23ffe8e56c2f20015e61d.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "INSERT INTO comms_messages (id, channel_id, thread_id, sender_id, content) VALUES ($1, $2, $3, $4, '')",
- "describe": {
- "columns": [],
- "parameters": {
- "Left": [
- "Uuid",
- "Uuid",
- "Uuid",
- "Text"
- ]
- },
- "nullable": []
- },
- "hash": "d786a1b974bb723a269cb31b36190910f5be0d1084e23ffe8e56c2f20015e61d"
-}
diff --git a/.sqlx/query-e18bdb73d69d37ea32745f55c613dc74c98c82cfd7c7bf32cd683b160823e8ac.json b/.sqlx/query-e18bdb73d69d37ea32745f55c613dc74c98c82cfd7c7bf32cd683b160823e8ac.json
deleted file mode 100644
index fb7ff9f34db..00000000000
--- a/.sqlx/query-e18bdb73d69d37ea32745f55c613dc74c98c82cfd7c7bf32cd683b160823e8ac.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "INSERT INTO comms_channels (id, channel_type, owner_id) VALUES ($1, 'private', $2)",
- "describe": {
- "columns": [],
- "parameters": {
- "Left": [
- "Uuid",
- "Text"
- ]
- },
- "nullable": []
- },
- "hash": "e18bdb73d69d37ea32745f55c613dc74c98c82cfd7c7bf32cd683b160823e8ac"
-}
diff --git a/.sqlx/query-e6527904d07cf679c44b58649f354320d566bd069ffd66d69142cd59c610a990.json b/.sqlx/query-e6527904d07cf679c44b58649f354320d566bd069ffd66d69142cd59c610a990.json
new file mode 100644
index 00000000000..f991a262256
--- /dev/null
+++ b/.sqlx/query-e6527904d07cf679c44b58649f354320d566bd069ffd66d69142cd59c610a990.json
@@ -0,0 +1,88 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT\n m.id,\n m.channel_id,\n m.sender_id,\n m.triggered_by_user_id,\n m.content,\n m.agent_session_message_id,\n m.created_at,\n m.updated_at,\n m.edited_at::timestamptz AS \"edited_at?\",\n m.deleted_at::timestamptz AS \"deleted_at?\"\n FROM comms_messages m\n WHERE m.channel_id = $1\n AND m.thread_id IS NULL\n AND (m.deleted_at IS NULL OR EXISTS (\n SELECT 1 FROM comms_messages r\n WHERE r.thread_id = m.id AND r.deleted_at IS NULL\n ))\n AND ($2::timestamptz IS NOT NULL AND (m.created_at, m.id) > ($2, $3))\n AND ($5::uuid[] IS NULL OR m.id = ANY($5))\n AND ($6::timestamptz IS NULL OR m.created_at >= $6)\n AND ($7::timestamptz IS NULL OR m.created_at < $7)\n AND (\n ($8::timestamptz IS NULL AND $9::timestamptz IS NULL)\n OR (\n ($8::timestamptz IS NULL OR m.created_at >= $8)\n AND ($9::timestamptz IS NULL OR m.created_at < $9)\n )\n OR EXISTS (\n SELECT 1 FROM comms_messages r\n WHERE r.thread_id = m.id\n AND r.deleted_at IS NULL\n AND ($8::timestamptz IS NULL OR r.created_at >= $8)\n AND ($9::timestamptz IS NULL OR r.created_at < $9)\n )\n )\n AND ($10::bool = FALSE OR (\n ($11::bool IS NULL OR EXISTS (\n SELECT 1\n FROM notification n\n JOIN user_notification un ON un.notification_id = n.id\n JOIN comms_messages msg ON msg.id = (n.metadata->>'messageId')::uuid\n WHERE un.user_id = $13::text\n AND un.deleted_at IS NULL\n AND un.done = $11\n AND n.event_item_type = 'channel'\n AND n.event_item_id = $1::uuid::text\n AND n.metadata->>'messageId' IS NOT NULL\n AND msg.channel_id = $1\n AND msg.deleted_at IS NULL\n AND COALESCE(msg.thread_id, msg.id) = m.id\n ))\n AND ($12::bool IS NULL OR EXISTS (\n SELECT 1\n FROM notification n\n JOIN user_notification un ON un.notification_id = n.id\n JOIN comms_messages msg ON msg.id = (n.metadata->>'messageId')::uuid\n WHERE un.user_id = $13::text\n AND un.deleted_at IS NULL\n AND (un.seen_at IS NOT NULL) = $12\n AND n.event_item_type = 'channel'\n AND n.event_item_id = $1::uuid::text\n AND n.metadata->>'messageId' IS NOT NULL\n AND msg.channel_id = $1\n AND msg.deleted_at IS NULL\n AND COALESCE(msg.thread_id, msg.id) = m.id\n ))\n ))\n ORDER BY m.created_at ASC, m.id ASC\n LIMIT $4\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 1,
+ "name": "channel_id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 2,
+ "name": "sender_id",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 3,
+ "name": "triggered_by_user_id",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 4,
+ "name": "content",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 5,
+ "name": "agent_session_message_id",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 6,
+ "name": "created_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 7,
+ "name": "updated_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 8,
+ "name": "edited_at?",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 9,
+ "name": "deleted_at?",
+ "type_info": "Timestamptz"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Uuid",
+ "Timestamptz",
+ "Uuid",
+ "Int8",
+ "UuidArray",
+ "Timestamptz",
+ "Timestamptz",
+ "Timestamptz",
+ "Timestamptz",
+ "Bool",
+ "Bool",
+ "Bool",
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ true,
+ true,
+ true,
+ false,
+ false,
+ null,
+ null
+ ]
+ },
+ "hash": "e6527904d07cf679c44b58649f354320d566bd069ffd66d69142cd59c610a990"
+}
diff --git a/.sqlx/query-ed2f2e964af409b89eceb93c1b4ee3364dbb75bdc22047789492b8f73b3e2491.json b/.sqlx/query-ed2f2e964af409b89eceb93c1b4ee3364dbb75bdc22047789492b8f73b3e2491.json
index 4c202caa698..141a67fc9d5 100644
--- a/.sqlx/query-ed2f2e964af409b89eceb93c1b4ee3364dbb75bdc22047789492b8f73b3e2491.json
+++ b/.sqlx/query-ed2f2e964af409b89eceb93c1b4ee3364dbb75bdc22047789492b8f73b3e2491.json
@@ -60,7 +60,7 @@
false,
false,
false,
- false,
+ true,
false,
false,
true,
diff --git a/.sqlx/query-eda8f3dae4d58225307ad036c6a19e0d6bef4a3d9ea2791d75799c93b4f2350f.json b/.sqlx/query-eda8f3dae4d58225307ad036c6a19e0d6bef4a3d9ea2791d75799c93b4f2350f.json
index bb45b730c40..5dec2c5994d 100644
--- a/.sqlx/query-eda8f3dae4d58225307ad036c6a19e0d6bef4a3d9ea2791d75799c93b4f2350f.json
+++ b/.sqlx/query-eda8f3dae4d58225307ad036c6a19e0d6bef4a3d9ea2791d75799c93b4f2350f.json
@@ -57,7 +57,7 @@
false,
true,
false,
- false,
+ true,
false,
false
]
diff --git a/.sqlx/query-b002a549f1a74fd65b2a24564ef8e35fa56a985e1f8eef8265139dd4a2777440.json b/.sqlx/query-fcdf7298d2531cba0e94412709534c18a686779bfe3a08cb83ee2130ec472fe3.json
similarity index 70%
rename from .sqlx/query-b002a549f1a74fd65b2a24564ef8e35fa56a985e1f8eef8265139dd4a2777440.json
rename to .sqlx/query-fcdf7298d2531cba0e94412709534c18a686779bfe3a08cb83ee2130ec472fe3.json
index 58ebd52eafc..4ccfe3a7b6b 100644
--- a/.sqlx/query-b002a549f1a74fd65b2a24564ef8e35fa56a985e1f8eef8265139dd4a2777440.json
+++ b/.sqlx/query-fcdf7298d2531cba0e94412709534c18a686779bfe3a08cb83ee2130ec472fe3.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n SELECT\n id,\n channel_id,\n thread_id,\n sender_id,\n triggered_by_user_id,\n content,\n created_at,\n updated_at,\n edited_at::timestamptz AS \"edited_at?\",\n deleted_at::timestamptz AS \"deleted_at?\"\n FROM comms_messages\n WHERE channel_id = $1\n AND (created_at, id) > ($2, $3)\n ORDER BY created_at ASC, id ASC\n LIMIT $4\n ",
+ "query": "\n SELECT\n id,\n channel_id,\n thread_id,\n sender_id,\n triggered_by_user_id,\n content,\n agent_session_message_id,\n created_at,\n updated_at,\n edited_at::timestamptz AS \"edited_at?\",\n deleted_at::timestamptz AS \"deleted_at?\"\n FROM comms_messages\n WHERE channel_id = $1\n AND (created_at, id) < ($2, $3)\n ORDER BY created_at DESC, id DESC\n LIMIT $4\n ",
"describe": {
"columns": [
{
@@ -35,21 +35,26 @@
},
{
"ordinal": 6,
+ "name": "agent_session_message_id",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 7,
"name": "created_at",
"type_info": "Timestamptz"
},
{
- "ordinal": 7,
+ "ordinal": 8,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
- "ordinal": 8,
+ "ordinal": 9,
"name": "edited_at?",
"type_info": "Timestamptz"
},
{
- "ordinal": 9,
+ "ordinal": 10,
"name": "deleted_at?",
"type_info": "Timestamptz"
}
@@ -68,12 +73,13 @@
true,
false,
true,
- false,
+ true,
+ true,
false,
false,
null,
null
]
},
- "hash": "b002a549f1a74fd65b2a24564ef8e35fa56a985e1f8eef8265139dd4a2777440"
+ "hash": "fcdf7298d2531cba0e94412709534c18a686779bfe3a08cb83ee2130ec472fe3"
}
diff --git a/Cargo.lock b/Cargo.lock
index 25930c4d6ce..4907ff78ab9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -134,7 +134,6 @@ version = "0.1.0"
dependencies = [
"agent-client-protocol",
"agent_runtime_protocol",
- "agent_session",
"clap",
"macro_user_id",
"macro_uuid",
@@ -153,6 +152,7 @@ name = "agent_harness"
version = "0.1.0"
dependencies = [
"agent-client-protocol",
+ "agent_fold",
"agent_runtime_protocol",
"agent_session",
"agent_trigger",
@@ -189,6 +189,7 @@ dependencies = [
name = "agent_harness_service"
version = "0.1.0"
dependencies = [
+ "agent_fold",
"agent_harness",
"agent_session",
"agent_trigger",
@@ -242,17 +243,21 @@ name = "agent_session"
version = "0.1.0"
dependencies = [
"agent-client-protocol",
+ "agent_fold",
"agent_runtime_protocol",
"anyhow",
"axum",
"bots",
"chrono",
+ "clap",
"dashmap",
"macro_authorization",
"macro_db_migrator",
"macro_user_id",
"macro_uuid",
"mockall",
+ "non_empty",
+ "rootcause",
"serde",
"serde_json",
"sqlx",
@@ -4574,6 +4579,7 @@ dependencies = [
name = "document_storage_service"
version = "0.1.0"
dependencies = [
+ "agent_fold",
"agent_session",
"ai_tools",
"ai_usage",
diff --git a/apps/web/src/features/channel/Channel/Channel.tsx b/apps/web/src/features/channel/Channel/Channel.tsx
index 68af3388090..4d5efb53afc 100644
--- a/apps/web/src/features/channel/Channel/Channel.tsx
+++ b/apps/web/src/features/channel/Channel/Channel.tsx
@@ -8,6 +8,7 @@ import {
makeInputValuePersistenceKey,
} from '@channel/Input/utils/persistence';
import {
+ FoldedMessagesProvider,
type MessageData,
SearchHighlightTermsProvider,
} from '@channel/Message';
@@ -22,7 +23,11 @@ import { useSplitPanel } from '@components/app/split-layout/layoutUtils';
import { FindBar } from '@core/component/FindBar';
import { StaticMarkdownContext } from '@core/component/LexicalMarkdown/component/core/StaticMarkdown';
import { toast } from '@core/component/Toast/Toast';
-import { useChannelActivity, useChannelName } from '@core/context/channels';
+import {
+ useChannel,
+ useChannelActivity,
+ useChannelName,
+} from '@core/context/channels';
import { useUserId } from '@core/context/user';
import { isMobile } from '@core/mobile/isMobile';
import type { DateValue } from '@core/util/date';
@@ -45,6 +50,10 @@ import {
isMissingChannelMessageError,
useChannelMessagesQuery,
} from '@queries/channel/channel-messages';
+import {
+ createFoldedMessageLookup,
+ useFoldedMessagesQuery,
+} from '@queries/channel/folded-messages';
import {
useDeleteMessageMutation,
usePatchMessageMutation,
@@ -220,6 +229,18 @@ export function Channel(props: ChannelProps) {
() => messagesQuery.data as ChannelMessagesData | undefined
);
+ // The dumb agent viewer: for an agent channel, fetch the session's folded
+ // messages and hand a message-id lookup down the message tree so placeholder
+ // rows (null content + agent_session_message_id) render their folded side.
+ const channel = useChannel(props.channelId);
+ const foldedMessagesQuery = useFoldedMessagesQuery(
+ () => props.channelId,
+ () => channel()?.kind === 'agent'
+ );
+ const foldedMessageLookup = createFoldedMessageLookup(
+ () => foldedMessagesQuery.data
+ );
+
const messages = createMemo(() => [...messageIndex.items]);
const messageById = () => messageIndex.byId;
const keepMountedTargetThreadIndexes = createMemo(() => {
@@ -373,7 +394,7 @@ export function Channel(props: ChannelProps) {
const beforeSnapshot = state.replyInputState();
const nextSnapshot: InputSnapshot = {
value: buildQuoteReplyValue({
- quotedContent: message.content,
+ quotedContent: message.content ?? '',
existingValue: beforeSnapshot?.value,
}),
mentions: beforeSnapshot?.mentions ?? [],
@@ -417,11 +438,11 @@ export function Channel(props: ChannelProps) {
messageEditor.start(message);
},
onCreateTask: (ctx) => {
- const trimmedMarkdown = trimEdgeUserMentions(ctx.message.content);
+ const trimmedMarkdown = trimEdgeUserMentions(ctx.message.content ?? '');
const plainText = markdownToPlainText(trimmedMarkdown).trim();
const title =
plainText.length > 70 ? `${plainText.slice(0, 70)}...` : plainText;
- const mentionedUserIds = extractUserMentions(ctx.message.content);
+ const mentionedUserIds = extractUserMentions(ctx.message.content ?? '');
popoverSplit({
type: 'component',
id: 'task-compose',
@@ -618,262 +639,266 @@ export function Channel(props: ChannelProps) {
-
-
- {
- attachMessageListRef(element);
- }}
- tabIndex={-1}
- data-channel-message-list
- >
-
-
-
-
0}>
-
-
+
+
+ {
+ attachMessageListRef(element);
+ }}
+ tabIndex={-1}
+ data-channel-message-list
+ >
+
+
+
+
0}>
+
- messageIndex.keys}
- initialScrollTarget={threadListInitialScrollTarget()}
- initialScrollHandledByTargetElement={
- targetMessageController.pendingScrollTargetId() !==
- undefined
- }
- keepMounted={keepMountedTargetThreadIndexes}
- fullFrameScrollInsets={threadListScrollInsets}
- shift={shift}
- prepend={threadPaginator.isPrepending}
- onScrollNearTop={threadPaginator.shiftPaginate}
- onScrollNearBottom={threadPaginator.prependPaginate}
- onNavigationReady={setThreadListNavigation}
- onScrollStateChange={setThreadListScrollState}
- initialScrollSnapshot={
- props.targetMessageId
- ? undefined
- : props.initialMessagesStateSnapshot?.scroll
- }
- onScrollSnapshotChange={setThreadListScrollSnapshot}
+
- {(item) => {
- const message = () => messageById().get(item.id);
- const state = threadManager.getOrCreateThreadState(
- item.id
- );
- const isNewestThread = () =>
- item.id === messageIndex.keys.at(-1);
-
- return (
-
- {(m) => (
- props.channelId}
- isNewestThread={isNewestThread()}
- getMessageActions={getMessageActions}
- isFindBarOpen={findBar.isOpen}
- targetNavigation={{
- targetThreadId:
- targetMessageController.activeTargetMessageId,
- targetMessageId: () =>
- !targetMessageController.pendingTargetReplyId()
- ? targetMessageController.pendingScrollTargetId()
- : undefined,
- targetReplyId: () =>
- targetMessageController.pendingScrollTargetId()
- ? undefined
- : targetMessageController.pendingTargetReplyId(),
- activeTargetReplyId:
- targetMessageController.activeTargetMessageReplyId,
- positionTarget: (
- threadRow,
- targetElement
- ) =>
- threadListNavigation()?.scrollToElementInItem(
- item.id,
+ messageIndex.keys}
+ initialScrollTarget={threadListInitialScrollTarget()}
+ initialScrollHandledByTargetElement={
+ targetMessageController.pendingScrollTargetId() !==
+ undefined
+ }
+ keepMounted={keepMountedTargetThreadIndexes}
+ fullFrameScrollInsets={threadListScrollInsets}
+ shift={shift}
+ prepend={threadPaginator.isPrepending}
+ onScrollNearTop={threadPaginator.shiftPaginate}
+ onScrollNearBottom={threadPaginator.prependPaginate}
+ onNavigationReady={setThreadListNavigation}
+ onScrollStateChange={setThreadListScrollState}
+ initialScrollSnapshot={
+ props.targetMessageId
+ ? undefined
+ : props.initialMessagesStateSnapshot?.scroll
+ }
+ onScrollSnapshotChange={setThreadListScrollSnapshot}
+ >
+ {(item) => {
+ const message = () => messageById().get(item.id);
+ const state = threadManager.getOrCreateThreadState(
+ item.id
+ );
+ const isNewestThread = () =>
+ item.id === messageIndex.keys.at(-1);
+
+ return (
+
+ {(m) => (
+ props.channelId}
+ isNewestThread={isNewestThread()}
+ getMessageActions={getMessageActions}
+ isFindBarOpen={findBar.isOpen}
+ targetNavigation={{
+ targetThreadId:
+ targetMessageController.activeTargetMessageId,
+ targetMessageId: () =>
+ !targetMessageController.pendingTargetReplyId()
+ ? targetMessageController.pendingScrollTargetId()
+ : undefined,
+ targetReplyId: () =>
+ targetMessageController.pendingScrollTargetId()
+ ? undefined
+ : targetMessageController.pendingTargetReplyId(),
+ activeTargetReplyId:
+ targetMessageController.activeTargetMessageReplyId,
+ positionTarget: (
threadRow,
targetElement
- ) ?? false,
- onTargetMessageScrolled:
- targetMessageController.completePendingScroll,
- onTargetReplyScrolled: (replyId) => {
- targetMessageController.completePendingReplyScroll(
- item.id,
- replyId
- );
- },
- onClearTarget: releaseSelectionAndTarget,
- }}
- unifiedReplyTarget={unifiedInput.replyTarget()}
- isExpanded={state.isExpanded}
- setIsExpanded={state.setIsExpanded}
- isReplying={state.isReplying}
- setIsReplying={state.setIsReplying}
- replyInputState={state.replyInputState}
- setReplyInputState={state.setReplyInputState}
- setReplyInputEl={state.setReplyInputEl}
- replyInputHandle={state.replyInputHandle}
- setReplyInputHandle={
- state.setReplyInputHandle
- }
- replyInputFocusRequest={
- state.replyInputFocusRequest
- }
- listMeta={listMetaByMessageId()[item.id]}
- messageEditor={messageEditor}
- participants={participants.users}
- threadActions={{
- onDismissNewMessages:
- activityTracker.dismissNewMessages,
- }}
- isNewMessage={activityTracker.isNewMessage}
- selectedMessageId={selection.selectedId}
- onSelectMessage={selectMessage}
- onClearSelection={clearSelection}
- messageListScopeId={messageListScopeId}
- />
- )}
-
- );
- }}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {
- attachInputRef(el);
- }}
- >
-
-
- {(_messageId) => (
-
- goToMessage(
- message.thread_id ?? message.id,
- message.thread_id ? message.id : undefined
- )
- }
- />
- )}
-
-
- {(threadId) => (
- {
- const target = unifiedInput.replyTarget();
- if (target?.message) return target.message;
- // A restored quote-reply has no resolvable
- // message (messageById only indexes thread
- // roots) — don't misattribute it to the root.
- if (target?.replyId) return undefined;
- return messageById().get(threadId);
- }}
- threadHasReplies={() =>
- (messageById().get(threadId)?.thread
- .reply_count ?? 0) > 0
- }
- onNavigateToTarget={() =>
- goToMessage(
- threadId,
- unifiedInput.replyTarget()?.replyId
- )
- }
- onExit={unifiedInput.closeReply}
- />
- )}
-
-
- {
- dragState.setAttachFilesToChannel(
- handle.attachFiles
+ ) =>
+ threadListNavigation()?.scrollToElementInItem(
+ item.id,
+ threadRow,
+ targetElement
+ ) ?? false,
+ onTargetMessageScrolled:
+ targetMessageController.completePendingScroll,
+ onTargetReplyScrolled: (replyId) => {
+ targetMessageController.completePendingReplyScroll(
+ item.id,
+ replyId
+ );
+ },
+ onClearTarget: releaseSelectionAndTarget,
+ }}
+ unifiedReplyTarget={unifiedInput.replyTarget()}
+ isExpanded={state.isExpanded}
+ setIsExpanded={state.setIsExpanded}
+ isReplying={state.isReplying}
+ setIsReplying={state.setIsReplying}
+ replyInputState={state.replyInputState}
+ setReplyInputState={
+ state.setReplyInputState
+ }
+ setReplyInputEl={state.setReplyInputEl}
+ replyInputHandle={state.replyInputHandle}
+ setReplyInputHandle={
+ state.setReplyInputHandle
+ }
+ replyInputFocusRequest={
+ state.replyInputFocusRequest
+ }
+ listMeta={listMetaByMessageId()[item.id]}
+ messageEditor={messageEditor}
+ participants={participants.users}
+ threadActions={{
+ onDismissNewMessages:
+ activityTracker.dismissNewMessages,
+ }}
+ isNewMessage={activityTracker.isNewMessage}
+ selectedMessageId={selection.selectedId}
+ onSelectMessage={selectMessage}
+ onClearSelection={clearSelection}
+ messageListScopeId={messageListScopeId}
+ />
+ )}
+
);
- dragState.setEntityMentionInputHandlers(handle);
- setChannelInputHandle(handle);
}}
- onChange={(snapshot) =>
- void setChannelInputSnapshot(snapshot)
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ attachInputRef(el);
+ }}
+ >
+
+
- typingMutation.mutate({
- channelId: props.channelId,
- action: 'start',
- })
+ keyed
+ >
+ {(_messageId) => (
+
+ goToMessage(
+ message.thread_id ?? message.id,
+ message.thread_id ? message.id : undefined
+ )
+ }
+ />
+ )}
+
+
- typingMutation.mutate({
+ keyed
+ >
+ {(threadId) => (
+ {
+ const target = unifiedInput.replyTarget();
+ if (target?.message) return target.message;
+ // A restored quote-reply has no resolvable
+ // message (messageById only indexes thread
+ // roots) — don't misattribute it to the root.
+ if (target?.replyId) return undefined;
+ return messageById().get(threadId);
+ }}
+ threadHasReplies={() =>
+ (messageById().get(threadId)?.thread
+ .reply_count ?? 0) > 0
+ }
+ onNavigateToTarget={() =>
+ goToMessage(
+ threadId,
+ unifiedInput.replyTarget()?.replyId
+ )
+ }
+ onExit={unifiedInput.closeReply}
+ />
+ )}
+
+
+
-
-
-
-
-
-
-
+ })}
+ onReady={(handle) => {
+ dragState.setAttachFilesToChannel(
+ handle.attachFiles
+ );
+ dragState.setEntityMentionInputHandlers(handle);
+ setChannelInputHandle(handle);
+ }}
+ onChange={(snapshot) =>
+ void setChannelInputSnapshot(snapshot)
+ }
+ onSend={onSend}
+ onStartTyping={() =>
+ typingMutation.mutate({
+ channelId: props.channelId,
+ action: 'start',
+ })
+ }
+ onStopTyping={() =>
+ typingMutation.mutate({
+ channelId: props.channelId,
+ action: 'stop',
+ })
+ }
+ />
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/features/channel/Channel/create-channel-message-actions.ts b/apps/web/src/features/channel/Channel/create-channel-message-actions.ts
index a351e4f649d..297d89892a9 100644
--- a/apps/web/src/features/channel/Channel/create-channel-message-actions.ts
+++ b/apps/web/src/features/channel/Channel/create-channel-message-actions.ts
@@ -167,7 +167,7 @@ export function createChannelMessageActions(
!isDeleted && message.content
? async () => {
try {
- await effects.copyToClipboard(message.content);
+ await effects.copyToClipboard(message.content ?? '');
effects.notifyCopyMessageTextSuccess();
} catch (error) {
effects.notifyCopyMessageTextFailure(error);
diff --git a/apps/web/src/features/channel/Channel/message-editing.ts b/apps/web/src/features/channel/Channel/message-editing.ts
index 1a9813bb9d3..4b177450ed0 100644
--- a/apps/web/src/features/channel/Channel/message-editing.ts
+++ b/apps/web/src/features/channel/Channel/message-editing.ts
@@ -37,7 +37,7 @@ export function messageAttachmentToInputAttachment(
export function buildMessageEditSnapshot(message: MessageData): InputSnapshot {
return {
- value: message.content,
+ value: message.content ?? '',
mentions: [],
attachments: message.attachments
.map(messageAttachmentToInputAttachment)
diff --git a/apps/web/src/features/channel/Message/ChannelMessage.tsx b/apps/web/src/features/channel/Message/ChannelMessage.tsx
index ed9ccd47c08..100bf7dbb72 100644
--- a/apps/web/src/features/channel/Message/ChannelMessage.tsx
+++ b/apps/web/src/features/channel/Message/ChannelMessage.tsx
@@ -8,7 +8,8 @@ import { type Accessor, type JSX, Match, Show, Switch } from 'solid-js';
import type { MessageEditor } from '../Channel/create-message-editor';
import { MessageEditorContent } from '../Channel/InlineMessageEditor';
import { isUnifiedInputMode } from '../unified-input-mode';
-import { useMessage } from './context';
+import { useFoldedMessageLookup, useMessage } from './context';
+import { FoldedContent } from './FoldedContent';
import type { ChannelMessageListMeta } from './list-meta';
import { Message } from './Message';
import { MaybeSwipeToReplyRow } from './SwipeToReplyRow';
@@ -122,6 +123,46 @@ function DeletedMessageLayout() {
);
}
+/**
+ * An agent-session placeholder: the comms row stores no content, only an
+ * `agent_session_message_id`, and the body is the folded message looked up
+ * from the channel's agent session. One row per folded message, so this
+ * renders the user's prompt as well as the agent's reply.
+ */
+function FoldedMessageLayout() {
+ const message = useMessage();
+ const lookup = useFoldedMessageLookup();
+ const folded = () => {
+ const messageId = message().agent_session_message_id;
+ return messageId ? lookup?.(messageId) : undefined;
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ Working…
}
+ >
+ {(folded) => }
+
+
+
+ );
+}
+
function RegularMessageLayout(props: {
channelId: string;
messageEditor?: MessageEditor;
@@ -228,6 +269,14 @@ export function ChannelMessage(props: ChannelMessageProps) {
+
+
+
diff.path).join(', ') || undefined,
+ };
+ case 'read':
+ return {
+ icon: FileText,
+ summary: detail.paths.join(', ') || undefined,
+ };
+ case 'other':
+ return { icon: Wrench, summary: undefined };
+ }
+}
+
+/** What the user chose on a permission request, as a trailing label. */
+function permissionOutcomeLabel(
+ part: Extract
+): string | undefined {
+ const outcome = part.outcome;
+ if (!outcome) return undefined;
+ if (outcome.kind === 'cancelled') return 'Cancelled';
+ const chosen = part.options.find((option) => option.id === outcome.optionId);
+ return chosen?.name ?? 'Answered';
+}
+
+/**
+ * The agent's reasoning, styled like the chat's `ThinkingBlock` but always
+ * open — this view has no collapsing.
+ */
+function Thought(props: { text: string }) {
+ return (
+
+ );
+}
+
+// Folded parts are plain immutable query data — a new array arrives on each
+// refetch — so rendering them non-reactively in a switch is safe.
+function FoldedPart(props: { part: FoldedMessagePartDto }): JSX.Element {
+ const part = props.part;
+ switch (part.kind) {
+ case 'text':
+ return (
+
+
+
+ );
+ case 'thought':
+ return ;
+ case 'tool_use': {
+ const { icon: Icon, summary } = toolPresentation(part.detail);
+ const failed = part.status === 'failed';
+ return (
+
+ Failed : undefined}
+ >
+
+ {part.label}
+
+ {(summary) => (
+ <>
+ ·
+ {summary()}
+ >
+ )}
+
+
+
+
+ );
+ }
+ case 'permission': {
+ const outcome = permissionOutcomeLabel(part);
+ return (
+
+ {outcome} : undefined
+ }
+ >
+ Permission requested
+
+
+ );
+ }
+ }
+}
+
+/**
+ * The dumb agent viewer: renders a folded agent-session message — prose,
+ * reasoning, and tool calls — in place of a placeholder channel message's
+ * missing content.
+ */
+export function FoldedContent(props: { folded: FoldedMessageDto }) {
+ return (
+
+
+ {(part) => }
+
+
+ );
+}
diff --git a/apps/web/src/features/channel/Message/context.ts b/apps/web/src/features/channel/Message/context.ts
index f1ff8f63c1f..db099b25b96 100644
--- a/apps/web/src/features/channel/Message/context.ts
+++ b/apps/web/src/features/channel/Message/context.ts
@@ -1,3 +1,4 @@
+import type { FoldedMessageLookup } from '@queries/channel/folded-messages';
import { type Accessor, createContext, useContext } from 'solid-js';
import type { MessageActions, MessageData } from './types';
@@ -10,10 +11,13 @@ export type SearchHighlightTermsLookup = (
const SearchHighlightTermsContext = createContext();
+const FoldedMessagesContext = createContext();
+
export const MessageProvider = MessageContext.Provider;
export const MessageActionsProvider = MessageActionsContext.Provider;
export const SearchHighlightTermsProvider =
SearchHighlightTermsContext.Provider;
+export const FoldedMessagesProvider = FoldedMessagesContext.Provider;
export function useSearchHighlightTermsLookup():
| SearchHighlightTermsLookup
@@ -21,6 +25,10 @@ export function useSearchHighlightTermsLookup():
return useContext(SearchHighlightTermsContext);
}
+export function useFoldedMessageLookup(): FoldedMessageLookup | undefined {
+ return useContext(FoldedMessagesContext);
+}
+
export function useMessage(): Accessor {
const ctx = useContext(MessageContext);
if (!ctx) throw new Error('useMessage must be used within ');
diff --git a/apps/web/src/features/channel/Message/index.ts b/apps/web/src/features/channel/Message/index.ts
index ccdb9b4d258..0c45a53107f 100644
--- a/apps/web/src/features/channel/Message/index.ts
+++ b/apps/web/src/features/channel/Message/index.ts
@@ -1,6 +1,8 @@
import {
+ FoldedMessagesProvider,
MessageActionsProvider,
SearchHighlightTermsProvider,
+ useFoldedMessageLookup,
useMessage,
useMessageActions,
useSearchHighlightTermsLookup,
@@ -25,8 +27,10 @@ export type {
MessageData,
} from './types';
export {
+ FoldedMessagesProvider,
MessageActionsProvider,
SearchHighlightTermsProvider,
+ useFoldedMessageLookup,
useMessage,
useMessageActions,
useSearchHighlightTermsLookup,
diff --git a/apps/web/src/features/channel/Message/types.ts b/apps/web/src/features/channel/Message/types.ts
index 846df03eb27..0fbc876fd5f 100644
--- a/apps/web/src/features/channel/Message/types.ts
+++ b/apps/web/src/features/channel/Message/types.ts
@@ -4,7 +4,13 @@ import type { ApiMessageSender } from '@service-storage/generated/schemas/apiMes
export type MessageData = {
id: string;
- content: string;
+ /** `null`/absent on agent-session placeholder messages. */
+ content?: string | null;
+ /**
+ * The folded agent-session message a placeholder renders, as the composite
+ * `"{agent_session_id}:{turn}:{author}"`. Set only on placeholders.
+ */
+ agent_session_message_id?: string | null;
sender_id: string;
/** Structured sender identity; carries bot name/avatar for bot senders. */
sender?: ApiMessageSender;
diff --git a/apps/web/src/features/channel/StandaloneThread/EditableThread.tsx b/apps/web/src/features/channel/StandaloneThread/EditableThread.tsx
index 9939bfd8427..dd0c7dc244f 100644
--- a/apps/web/src/features/channel/StandaloneThread/EditableThread.tsx
+++ b/apps/web/src/features/channel/StandaloneThread/EditableThread.tsx
@@ -47,7 +47,7 @@ function EditableThreadInner() {
const current = replyInputState();
const nextSnapshot: InputSnapshot = {
value: buildQuoteReplyValue({
- quotedContent: message.content,
+ quotedContent: message.content ?? '',
existingValue: current?.value,
}),
mentions: current?.mentions ?? [],
diff --git a/apps/web/src/lib/core/component/DocumentPreview.tsx b/apps/web/src/lib/core/component/DocumentPreview.tsx
index 92688ba9054..946f6bfc83e 100644
--- a/apps/web/src/lib/core/component/DocumentPreview.tsx
+++ b/apps/web/src/lib/core/component/DocumentPreview.tsx
@@ -841,7 +841,7 @@ export function DocumentPreviewContent(props: DocumentPreviewContentProps) {
diff --git a/apps/web/src/lib/queries/channel/folded-messages.ts b/apps/web/src/lib/queries/channel/folded-messages.ts
new file mode 100644
index 00000000000..f701c162ec3
--- /dev/null
+++ b/apps/web/src/lib/queries/channel/folded-messages.ts
@@ -0,0 +1,63 @@
+import { thrownResultErrorHasCode, throwOnErr } from '@core/util/result';
+import { storageServiceClient } from '@service-storage/client';
+import type { AgentChannelMessagesResponse } from '@service-storage/generated/schemas/agentChannelMessagesResponse';
+import type { FoldedMessageDto } from '@service-storage/generated/schemas/foldedMessageDto';
+import { useQuery } from '@tanstack/solid-query';
+import { type Accessor, createMemo } from 'solid-js';
+import { channelKeys } from './keys';
+
+/**
+ * Look up the folded agent-session message a placeholder channel message
+ * renders, by the placeholder's `agent_session_message_id`.
+ */
+export type FoldedMessageLookup = (
+ messageId: string
+) => FoldedMessageDto | undefined;
+
+/**
+ * The folded messages of the agent session behind a channel.
+ *
+ * Enable only for channels of kind `agent`; a channel without a session 404s.
+ * Nothing invalidates this over the websocket yet, so a short stale time
+ * keeps reopened channels reasonably fresh without hammering the fold.
+ */
+export function useFoldedMessagesQuery(
+ channelId: Accessor
,
+ enabled: Accessor
+) {
+ return useQuery(() => ({
+ queryKey: channelKeys.foldedMessages(channelId()).queryKey,
+ queryFn: async () =>
+ await throwOnErr(
+ async () =>
+ await storageServiceClient.getAgentChannelMessages({
+ channel_id: channelId(),
+ })
+ ),
+ enabled: enabled(),
+ staleTime: 30_000,
+ retry: (failureCount: number, error: Error) =>
+ !thrownResultErrorHasCode(error, 'NOT_FOUND') && failureCount < 1,
+ }));
+}
+
+/**
+ * A reactive `agentSessionMessageId -> folded message` lookup.
+ *
+ * Every folded message is indexed, user prompts included: placeholders are
+ * keyed per message rather than per turn, so a turn's prompt and its reply
+ * are separate channel rows and each resolves to its own side.
+ */
+export function createFoldedMessageLookup(
+ data: Accessor
+): FoldedMessageLookup {
+ const byMessageId = createMemo(() => {
+ const map = new Map();
+ for (const message of data()?.messages ?? []) {
+ map.set(message.agentSessionMessageId, message);
+ }
+ return map;
+ });
+
+ return (messageId) => byMessageId().get(messageId);
+}
diff --git a/apps/web/src/lib/queries/channel/keys.ts b/apps/web/src/lib/queries/channel/keys.ts
index bf8e5687825..2fdd14e0248 100644
--- a/apps/web/src/lib/queries/channel/keys.ts
+++ b/apps/web/src/lib/queries/channel/keys.ts
@@ -25,6 +25,9 @@ export const channelKeys = createQueryKeys('channel', {
threadReplies: (channelID: string, messageID: string) => ({
queryKey: [channelID, messageID],
}),
+ foldedMessages: (channelID: string) => ({
+ queryKey: [channelID],
+ }),
resolveMessage: (channelID: string, messageID: string) => ({
queryKey: [channelID, messageID],
}),
diff --git a/apps/web/src/lib/queries/channel/message.ts b/apps/web/src/lib/queries/channel/message.ts
index a7f80dc66f3..e665fcf0274 100644
--- a/apps/web/src/lib/queries/channel/message.ts
+++ b/apps/web/src/lib/queries/channel/message.ts
@@ -381,7 +381,7 @@ export function optimisticUpdateChannelMessage(
}));
replaceTargetMessageState(vars.channelId, target, {
- content: vars.content,
+ content: vars.content ?? '',
editedAt: now,
updatedAt: now,
attachments: [...kept, ...added],
diff --git a/apps/web/src/lib/queries/channel/reconcile.ts b/apps/web/src/lib/queries/channel/reconcile.ts
index 537a56112cb..9985b0fb0ac 100644
--- a/apps/web/src/lib/queries/channel/reconcile.ts
+++ b/apps/web/src/lib/queries/channel/reconcile.ts
@@ -449,7 +449,7 @@ export function getTargetMessageState(
if (!reply) return;
return {
- content: reply.content,
+ content: reply.content ?? '',
editedAt: reply.edited_at,
updatedAt: reply.updated_at,
attachments: reply.attachments,
@@ -463,7 +463,7 @@ export function getTargetMessageState(
if (!message) return;
return {
- content: message.content,
+ content: message.content ?? '',
editedAt: message.edited_at,
updatedAt: message.updated_at,
attachments: message.attachments,
diff --git a/apps/web/src/lib/queries/channel/sync.ts b/apps/web/src/lib/queries/channel/sync.ts
index 6cbe93da309..f21c3f2fc64 100644
--- a/apps/web/src/lib/queries/channel/sync.ts
+++ b/apps/web/src/lib/queries/channel/sync.ts
@@ -90,7 +90,7 @@ export function handleCommsMessage(payload: CommsMessagePayload): void {
if (existingState) {
replaceTargetMessageState(payload.channel_id, target, {
- content: payload.content,
+ content: payload.content ?? '',
editedAt: payload.edited_at,
updatedAt: payload.updated_at,
attachments: existingState.attachments,
diff --git a/apps/web/src/lib/queries/soup/transform-utils.ts b/apps/web/src/lib/queries/soup/transform-utils.ts
index 07df7e111d9..299b65e3bdd 100644
--- a/apps/web/src/lib/queries/soup/transform-utils.ts
+++ b/apps/web/src/lib/queries/soup/transform-utils.ts
@@ -728,7 +728,7 @@ export const mapApiSoupItemToEntity = (
threadId: item.data.id,
senderId: item.data.sender_id,
sender: item.data.sender,
- content: item.data.content,
+ content: item.data.content ?? '',
attachments: item.data.attachments,
reactions: item.data.reactions,
ownerId: item.data.sender_id,
@@ -768,7 +768,7 @@ export const mapApiSoupItemToEntity = (
? {
messageId: latestMessage.message_id,
threadId: latestMessage.thread_id ?? undefined,
- content: latestMessage.content,
+ content: latestMessage.content ?? '',
mentions: latestMessage.mentions,
senderId: latestMessage.sender_id,
createdAt: latestMessage.created_at,
@@ -778,7 +778,7 @@ export const mapApiSoupItemToEntity = (
? {
messageId: latestRootMessage.message_id,
threadId: latestRootMessage.thread_id ?? undefined,
- content: latestRootMessage.content,
+ content: latestRootMessage.content ?? '',
mentions: latestRootMessage.mentions,
senderId: latestRootMessage.sender_id,
createdAt: latestRootMessage.created_at,
diff --git a/apps/web/src/lib/service-clients/service-agent-proxy/generated/client.ts b/apps/web/src/lib/service-clients/service-agent-proxy/generated/client.ts
index dbc1ae9ac49..1e9742b4e4c 100644
--- a/apps/web/src/lib/service-clients/service-agent-proxy/generated/client.ts
+++ b/apps/web/src/lib/service-clients/service-agent-proxy/generated/client.ts
@@ -5,6 +5,7 @@
* OpenAPI spec version: 0.1.0
*/
import type {
+ ConnectAgentRuntimeParams,
CreateAgentRequest,
EmptyResponse,
GetAgentResponse,
@@ -349,6 +350,73 @@ export const agentProxyHealth = async (
} as agentProxyHealthResponse;
};
+/**
+ * One endpoint serves every session, disambiguated by `?id=` rather than a
+listener per session. `agent_runtime_protocol` deliberately carries no
+session identifier on the wire - a connection hosts exactly one agent
+execution, so the wire protocol has no routing table - which makes this
+handler the place that identifier actually lives: matched against the
+query parameter the runtime dialed with, then handed on the same way for
+every session.
+ * @summary Accept one agent runtime's WebSocket connection.
+ */
+export type connectAgentRuntimeResponse101 = {
+ data: void;
+ status: 101;
+};
+
+export type connectAgentRuntimeResponse400 = {
+ data: void;
+ status: 400;
+};
+export type connectAgentRuntimeResponseError = (
+ | connectAgentRuntimeResponse101
+ | connectAgentRuntimeResponse400
+) & {
+ headers: Headers;
+};
+
+export type connectAgentRuntimeResponse = connectAgentRuntimeResponseError;
+
+export const getConnectAgentRuntimeUrl = (
+ params: ConnectAgentRuntimeParams
+) => {
+ const normalizedParams = new URLSearchParams();
+
+ Object.entries(params || {}).forEach(([key, value]) => {
+ if (value !== undefined) {
+ normalizedParams.append(key, value === null ? 'null' : value.toString());
+ }
+ });
+
+ const stringifiedParams = normalizedParams.toString();
+
+ return stringifiedParams.length > 0
+ ? `/runtime?${stringifiedParams}`
+ : `/runtime`;
+};
+
+export const connectAgentRuntime = async (
+ params: ConnectAgentRuntimeParams,
+ options?: RequestInit
+): Promise => {
+ const res = await fetch(getConnectAgentRuntimeUrl(params), {
+ ...options,
+ method: 'GET',
+ });
+
+ const body = [204, 205, 304].includes(res.status) ? null : await res.text();
+
+ const data: connectAgentRuntimeResponse['data'] = body
+ ? JSON.parse(body)
+ : {};
+ return {
+ data,
+ status: res.status,
+ headers: res.headers,
+ } as connectAgentRuntimeResponse;
+};
+
/**
* @summary Post one ACP JSON-RPC message to an agent session. The body is a complete
ACP JSON-RPC message (request, notification, or response); replies from
diff --git a/apps/web/src/lib/service-clients/service-agent-proxy/generated/schemas/connectAgentRuntimeParams.ts b/apps/web/src/lib/service-clients/service-agent-proxy/generated/schemas/connectAgentRuntimeParams.ts
new file mode 100644
index 00000000000..87b96c0a80a
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-agent-proxy/generated/schemas/connectAgentRuntimeParams.ts
@@ -0,0 +1,13 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * agent_proxy
+ * OpenAPI spec version: 0.1.0
+ */
+
+export type ConnectAgentRuntimeParams = {
+ /**
+ * ID of the agent session (chat) this runtime hosts
+ */
+ id: string;
+};
diff --git a/apps/web/src/lib/service-clients/service-agent-proxy/generated/schemas/entityType.ts b/apps/web/src/lib/service-clients/service-agent-proxy/generated/schemas/entityType.ts
index 649e23aca5b..3db5b1e30cc 100644
--- a/apps/web/src/lib/service-clients/service-agent-proxy/generated/schemas/entityType.ts
+++ b/apps/web/src/lib/service-clients/service-agent-proxy/generated/schemas/entityType.ts
@@ -19,6 +19,7 @@ export const EntityType = {
document: 'document',
project: 'project',
email_thread: 'email_thread',
+ calendar_event: 'calendar_event',
team: 'team',
call: 'call',
foreign_entity: 'foreign_entity',
diff --git a/apps/web/src/lib/service-clients/service-agent-proxy/generated/schemas/index.ts b/apps/web/src/lib/service-clients/service-agent-proxy/generated/schemas/index.ts
index 4aabff84860..163bd9b510e 100644
--- a/apps/web/src/lib/service-clients/service-agent-proxy/generated/schemas/index.ts
+++ b/apps/web/src/lib/service-clients/service-agent-proxy/generated/schemas/index.ts
@@ -28,6 +28,7 @@ export * from './chatResponseCreatedAt';
export * from './chatResponseModel';
export * from './chatResponseProjectId';
export * from './chatResponseUpdatedAt';
+export * from './connectAgentRuntimeParams';
export * from './createAgentRequest';
export * from './createAgentRequestKind';
export * from './createAgentRequestName';
diff --git a/apps/web/src/lib/service-clients/service-agent-proxy/openapi.json b/apps/web/src/lib/service-clients/service-agent-proxy/openapi.json
index 1e436a70067..323d6e1906b 100644
--- a/apps/web/src/lib/service-clients/service-agent-proxy/openapi.json
+++ b/apps/web/src/lib/service-clients/service-agent-proxy/openapi.json
@@ -327,6 +327,34 @@
}
}
},
+ "/runtime": {
+ "get": {
+ "tags": ["agent proxy"],
+ "summary": "Accept one agent runtime's WebSocket connection.",
+ "description": "One endpoint serves every session, disambiguated by `?id=` rather than a\nlistener per session. `agent_runtime_protocol` deliberately carries no\nsession identifier on the wire - a connection hosts exactly one agent\nexecution, so the wire protocol has no routing table - which makes this\nhandler the place that identifier actually lives: matched against the\nquery parameter the runtime dialed with, then handed on the same way for\nevery session.",
+ "operationId": "connect_agent_runtime",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "query",
+ "description": "ID of the agent session (chat) this runtime hosts",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "format": "uuid"
+ }
+ }
+ ],
+ "responses": {
+ "101": {
+ "description": "WebSocket upgrade; carries the agent-runtime protocol"
+ },
+ "400": {
+ "description": "missing or malformed session id"
+ }
+ }
+ }
+ },
"/sessions/{session_id}/acp": {
"post": {
"tags": ["agent proxy"],
@@ -694,6 +722,7 @@
"document",
"project",
"email_thread",
+ "calendar_event",
"team",
"call",
"foreign_entity",
diff --git a/apps/web/src/lib/service-clients/service-storage/channel-list-types.ts b/apps/web/src/lib/service-clients/service-storage/channel-list-types.ts
index be19318f9b2..5f08bfa1427 100644
--- a/apps/web/src/lib/service-clients/service-storage/channel-list-types.ts
+++ b/apps/web/src/lib/service-clients/service-storage/channel-list-types.ts
@@ -4,6 +4,7 @@
// generated service-storage primitives. (Previously generated as `@service-comms`'s
// `ApiChannelWithLatest`; service-storage's generated `SoupChannel` nests these under
// `.channel` and is therefore not interchangeable.)
+import type { ApiChannelKind } from './generated/schemas/apiChannelKind';
import type { Channel } from './generated/schemas/channel';
import type { ChannelParticipant } from './generated/schemas/channelParticipant';
import type { LatestMessage } from './generated/schemas/latestMessage';
@@ -12,6 +13,8 @@ import type { LatestMessage } from './generated/schemas/latestMessage';
export type ChannelWithParticipants = Channel & {
/** Whether current and future team members automatically join the channel. */
auto_join_team: boolean;
+ /** `normal`, or `agent` for an agent session's dedicated channel. */
+ kind: ApiChannelKind;
participants: ChannelParticipant[];
/** Whether the requesting user is an active participant of the channel. */
is_participant: boolean;
diff --git a/apps/web/src/lib/service-clients/service-storage/client.ts b/apps/web/src/lib/service-clients/service-storage/client.ts
index 186318164c7..d30126ab6ef 100644
--- a/apps/web/src/lib/service-clients/service-storage/client.ts
+++ b/apps/web/src/lib/service-clients/service-storage/client.ts
@@ -33,6 +33,7 @@ import type { AccessLevel } from './generated/schemas/accessLevel';
import type { AddFavoriteRequest } from './generated/schemas/addFavoriteRequest';
import type { AddParticipantsRequest } from './generated/schemas/addParticipantsRequest';
import type { AddPinRequest } from './generated/schemas/addPinRequest';
+import type { AgentChannelMessagesResponse } from './generated/schemas/agentChannelMessagesResponse';
import type { AnchorResponse } from './generated/schemas/anchorResponse';
import type { ApiActivity } from './generated/schemas/apiActivity';
import type { ApiChannelAttachmentsPage } from './generated/schemas/apiChannelAttachmentsPage';
@@ -895,6 +896,21 @@ export const storageServiceClient = {
).map((result) => result);
},
+ /**
+ * Folded messages of the agent session behind a channel. `404` when the
+ * channel has no agent session; each message's `agentSessionTurnId` matches
+ * the `agent_session_turn_id` on the channel's placeholder messages.
+ */
+ async getAgentChannelMessages(args: WithChannelId) {
+ const { channel_id } = args;
+ return (
+ await dssFetch(
+ `/agent-sessions/channel/${channel_id}/messages`,
+ { method: 'GET' }
+ )
+ ).map((result) => result);
+ },
+
async getThreadReplies(args: WithChannelId & WithMessageId) {
const { channel_id, message_id } = args;
return (
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/agentChannelMessagesResponse.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/agentChannelMessagesResponse.ts
new file mode 100644
index 00000000000..edadbfc749c
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/agentChannelMessagesResponse.ts
@@ -0,0 +1,17 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+import type { FoldedMessageDto } from './foldedMessageDto';
+
+/**
+ * Response body for a channel's folded agent-session messages.
+ */
+export interface AgentChannelMessagesResponse {
+ /** The session whose log derived the messages. */
+ agentSessionId: string;
+ /** The session's folded messages, oldest first. */
+ messages: FoldedMessageDto[];
+}
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiAttachmentChannelReference.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiAttachmentChannelReference.ts
index e613d2cff44..89e8e435ea4 100644
--- a/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiAttachmentChannelReference.ts
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiAttachmentChannelReference.ts
@@ -5,6 +5,7 @@
* OpenAPI spec version: 0.1.0
*/
import type { ApiAttachmentChannelReferenceChannelName } from './apiAttachmentChannelReferenceChannelName';
+import type { ApiAttachmentChannelReferenceMessageContent } from './apiAttachmentChannelReferenceMessageContent';
import type { ApiAttachmentChannelReferenceThreadId } from './apiAttachmentChannelReferenceThreadId';
/**
@@ -17,8 +18,9 @@ export interface ApiAttachmentChannelReference {
channel_id: string;
/** Optional channel name (DMs do not have a name). */
channel_name?: ApiAttachmentChannelReferenceChannelName;
- /** Full message content (might be used for preview/snippet). */
- message_content: string;
+ /** Full message content (might be used for preview/snippet). `None` on
+agent-turn placeholder messages. */
+ message_content?: ApiAttachmentChannelReferenceMessageContent;
/** When the message itself was created. */
message_created_at: string;
/** Message that contains the attachment reference. */
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiAttachmentChannelReferenceMessageContent.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiAttachmentChannelReferenceMessageContent.ts
new file mode 100644
index 00000000000..666ccdcdb92
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiAttachmentChannelReferenceMessageContent.ts
@@ -0,0 +1,12 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+/**
+ * Full message content (might be used for preview/snippet). `None` on
+agent-turn placeholder messages.
+ */
+export type ApiAttachmentChannelReferenceMessageContent = string | null;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelContextMessage.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelContextMessage.ts
index 61aff2ebc99..8b2e72670af 100644
--- a/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelContextMessage.ts
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelContextMessage.ts
@@ -4,6 +4,8 @@
* document_storage_service
* OpenAPI spec version: 0.1.0
*/
+import type { ApiChannelContextMessageAgentSessionMessageId } from './apiChannelContextMessageAgentSessionMessageId';
+import type { ApiChannelContextMessageContent } from './apiChannelContextMessageContent';
import type { ApiChannelContextMessageDeletedAt } from './apiChannelContextMessageDeletedAt';
import type { ApiChannelContextMessageEditedAt } from './apiChannelContextMessageEditedAt';
import type { ApiChannelContextMessageThreadId } from './apiChannelContextMessageThreadId';
@@ -13,10 +15,13 @@ import type { ApiMessageSender } from './apiMessageSender';
* A channel message returned by the message-context endpoint.
*/
export interface ApiChannelContextMessage {
+ /** The agent session turn this placeholder renders, as the composite
+`"{agent_session_id}:{turn}"`. Set only on agent-turn placeholders. */
+ agent_session_message_id?: ApiChannelContextMessageAgentSessionMessageId;
/** Channel id. */
channel_id: string;
- /** Message content. */
- content: string;
+ /** Message content. `None` on agent-turn placeholder messages. */
+ content?: ApiChannelContextMessageContent;
/** When the message was created. */
created_at: string;
/** When the message was soft-deleted. */
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelContextMessageAgentSessionMessageId.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelContextMessageAgentSessionMessageId.ts
new file mode 100644
index 00000000000..5e19b6bc273
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelContextMessageAgentSessionMessageId.ts
@@ -0,0 +1,12 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+/**
+ * The agent session turn this placeholder renders, as the composite
+`"{agent_session_id}:{turn}"`. Set only on agent-turn placeholders.
+ */
+export type ApiChannelContextMessageAgentSessionMessageId = string | null;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelContextMessageContent.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelContextMessageContent.ts
new file mode 100644
index 00000000000..3976a59df07
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelContextMessageContent.ts
@@ -0,0 +1,11 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+/**
+ * Message content. `None` on agent-turn placeholder messages.
+ */
+export type ApiChannelContextMessageContent = string | null;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelKind.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelKind.ts
new file mode 100644
index 00000000000..026b4a25eaf
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelKind.ts
@@ -0,0 +1,18 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+/**
+ * Channel kind in API responses.
+ */
+export type ApiChannelKind =
+ (typeof ApiChannelKind)[keyof typeof ApiChannelKind];
+
+// eslint-disable-next-line @typescript-eslint/no-redeclare
+export const ApiChannelKind = {
+ normal: 'normal',
+ agent: 'agent',
+} as const;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelListMessage.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelListMessage.ts
index 31c13b3cdde..9e50e254db2 100644
--- a/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelListMessage.ts
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelListMessage.ts
@@ -4,6 +4,7 @@
* document_storage_service
* OpenAPI spec version: 0.1.0
*/
+import type { ApiChannelListMessageContent } from './apiChannelListMessageContent';
import type { ApiChannelListMessageDeletedAt } from './apiChannelListMessageDeletedAt';
import type { ApiChannelListMessageThreadId } from './apiChannelListMessageThreadId';
@@ -11,8 +12,8 @@ import type { ApiChannelListMessageThreadId } from './apiChannelListMessageThrea
* Channel message in API responses.
*/
export interface ApiChannelListMessage {
- /** Message content. */
- content: string;
+ /** Message content. `None` on agent-turn placeholder messages. */
+ content?: ApiChannelListMessageContent;
/** Creation timestamp. */
created_at: string;
/** Deletion timestamp, if deleted. */
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelListMessageContent.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelListMessageContent.ts
new file mode 100644
index 00000000000..99149e96958
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelListMessageContent.ts
@@ -0,0 +1,11 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+/**
+ * Message content. `None` on agent-turn placeholder messages.
+ */
+export type ApiChannelListMessageContent = string | null;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelMessage.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelMessage.ts
index b1b363a82db..0087a4b13ce 100644
--- a/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelMessage.ts
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelMessage.ts
@@ -4,7 +4,8 @@
* document_storage_service
* OpenAPI spec version: 0.1.0
*/
-
+import type { ApiChannelMessageAgentSessionMessageId } from './apiChannelMessageAgentSessionMessageId';
+import type { ApiChannelMessageContent } from './apiChannelMessageContent';
import type { ApiChannelMessageDeletedAt } from './apiChannelMessageDeletedAt';
import type { ApiChannelMessageEditedAt } from './apiChannelMessageEditedAt';
import type { ApiCountedReaction } from './apiCountedReaction';
@@ -16,12 +17,17 @@ import type { ApiThreadInfo } from './apiThreadInfo';
* A top-level channel message with thread info.
*/
export interface ApiChannelMessage {
+ /** The agent session turn this placeholder renders, as the composite
+`"{agent_session_id}:{turn}"`. Set only on agent-turn placeholders;
+join it against the folded messages of the channel's agent session. */
+ agent_session_message_id?: ApiChannelMessageAgentSessionMessageId;
/** Attachments on this message. */
attachments: ApiMessageAttachment[];
/** Channel id. */
channel_id: string;
- /** Message content. */
- content: string;
+ /** Message content. `None` on agent-turn placeholder messages, whose body
+is folded from the agent session log and joined in by the client. */
+ content?: ApiChannelMessageContent;
/** When the message was created. */
created_at: string;
/** When the message was soft-deleted. */
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelMessageAgentSessionMessageId.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelMessageAgentSessionMessageId.ts
new file mode 100644
index 00000000000..bca1fd432d3
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelMessageAgentSessionMessageId.ts
@@ -0,0 +1,13 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+/**
+ * The agent session turn this placeholder renders, as the composite
+`"{agent_session_id}:{turn}"`. Set only on agent-turn placeholders;
+join it against the folded messages of the channel's agent session.
+ */
+export type ApiChannelMessageAgentSessionMessageId = string | null;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelMessageContent.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelMessageContent.ts
new file mode 100644
index 00000000000..95e74d0383b
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelMessageContent.ts
@@ -0,0 +1,12 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+/**
+ * Message content. `None` on agent-turn placeholder messages, whose body
+is folded from the agent session log and joined in by the client.
+ */
+export type ApiChannelMessageContent = string | null;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelWithLatest.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelWithLatest.ts
index f04b063ea0a..59f409881e7 100644
--- a/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelWithLatest.ts
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelWithLatest.ts
@@ -5,6 +5,7 @@
* OpenAPI spec version: 0.1.0
*/
+import type { ApiChannelKind } from './apiChannelKind';
import type { ApiChannelListParticipant } from './apiChannelListParticipant';
import type { ApiChannelListType } from './apiChannelListType';
import type { ApiChannelWithLatestFrecencyScore } from './apiChannelWithLatestFrecencyScore';
@@ -34,6 +35,9 @@ export interface ApiChannelWithLatest {
interacted_at?: ApiChannelWithLatestInteractedAt;
/** Whether the requesting user is an active participant of the channel. */
is_participant: boolean;
+ /** Channel kind: `normal`, or `agent` for an agent session's dedicated
+channel whose agent messages are folded placeholders. */
+ kind: ApiChannelKind;
latest_message?: ApiChannelWithLatestLatestMessage;
latest_non_thread_message?: ApiChannelWithLatestLatestNonThreadMessage;
/** Channel name. */
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiThreadReply.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiThreadReply.ts
index f7f7c00fc97..a1c2264a4fb 100644
--- a/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiThreadReply.ts
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiThreadReply.ts
@@ -8,16 +8,21 @@
import type { ApiCountedReaction } from './apiCountedReaction';
import type { ApiMessageAttachment } from './apiMessageAttachment';
import type { ApiMessageSender } from './apiMessageSender';
+import type { ApiThreadReplyAgentSessionMessageId } from './apiThreadReplyAgentSessionMessageId';
+import type { ApiThreadReplyContent } from './apiThreadReplyContent';
import type { ApiThreadReplyEditedAt } from './apiThreadReplyEditedAt';
/**
* A thread reply shown in preview.
*/
export interface ApiThreadReply {
+ /** The agent session turn this placeholder renders, as the composite
+`"{agent_session_id}:{turn}"`. Set only on agent-turn placeholders. */
+ agent_session_message_id?: ApiThreadReplyAgentSessionMessageId;
/** Attachments on this reply. */
attachments: ApiMessageAttachment[];
- /** Reply content. */
- content: string;
+ /** Reply content. `None` on agent-turn placeholder messages. */
+ content?: ApiThreadReplyContent;
/** When the reply was created. */
created_at: string;
/** When the reply was edited. */
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiThreadReplyAgentSessionMessageId.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiThreadReplyAgentSessionMessageId.ts
new file mode 100644
index 00000000000..a87b745010f
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiThreadReplyAgentSessionMessageId.ts
@@ -0,0 +1,12 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+/**
+ * The agent session turn this placeholder renders, as the composite
+`"{agent_session_id}:{turn}"`. Set only on agent-turn placeholders.
+ */
+export type ApiThreadReplyAgentSessionMessageId = string | null;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiThreadReplyContent.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiThreadReplyContent.ts
new file mode 100644
index 00000000000..82cda960edc
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/apiThreadReplyContent.ts
@@ -0,0 +1,11 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+/**
+ * Reply content. `None` on agent-turn placeholder messages.
+ */
+export type ApiThreadReplyContent = string | null;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/channelMessage.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/channelMessage.ts
index 4e0f95c03a7..d22fdea320a 100644
--- a/apps/web/src/lib/service-clients/service-storage/generated/schemas/channelMessage.ts
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/channelMessage.ts
@@ -4,6 +4,7 @@
* document_storage_service
* OpenAPI spec version: 0.1.0
*/
+import type { ChannelMessageContent } from './channelMessageContent';
import type { ChannelMessageDeletedAt } from './channelMessageDeletedAt';
import type { ChannelMessageThreadId } from './channelMessageThreadId';
@@ -11,8 +12,8 @@ import type { ChannelMessageThreadId } from './channelMessageThreadId';
* Lightweight channel message for soup payloads.
*/
export interface ChannelMessage {
- /** Message content. */
- content: string;
+ /** Message content. `None` on agent-turn placeholder messages. */
+ content?: ChannelMessageContent;
/** Creation timestamp. */
created_at: string;
/** Deletion timestamp. */
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/channelMessageContent.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/channelMessageContent.ts
new file mode 100644
index 00000000000..11e94632af6
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/channelMessageContent.ts
@@ -0,0 +1,11 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+/**
+ * Message content. `None` on agent-turn placeholder messages.
+ */
+export type ChannelMessageContent = string | null;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/fileDiffDto.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/fileDiffDto.ts
new file mode 100644
index 00000000000..ba81950bf56
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/fileDiffDto.ts
@@ -0,0 +1,19 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+import type { FileDiffDtoOldText } from './fileDiffDtoOldText';
+
+/**
+ * A file modification a tool reported, mirroring [`FileDiff`].
+ */
+export interface FileDiffDto {
+ /** New contents. */
+ newText: string;
+ /** Prior contents, absent when the file is new. */
+ oldText?: FileDiffDtoOldText;
+ /** The file that changed. */
+ path: string;
+}
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/fileDiffDtoOldText.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/fileDiffDtoOldText.ts
new file mode 100644
index 00000000000..cd40462f862
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/fileDiffDtoOldText.ts
@@ -0,0 +1,11 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+/**
+ * Prior contents, absent when the file is new.
+ */
+export type FileDiffDtoOldText = string | null;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedAuthorDto.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedAuthorDto.ts
new file mode 100644
index 00000000000..c5f5c5ad548
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedAuthorDto.ts
@@ -0,0 +1,18 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+import type { FoldedAuthorDtoOneOf } from './foldedAuthorDtoOneOf';
+import type { FoldedAuthorDtoOneOfFour } from './foldedAuthorDtoOneOfFour';
+
+/**
+ * Who produced a folded message, mirroring [`Author`].
+
+Multi-word fields in these enums carry explicit `#[serde(rename)]`s
+instead of `rename_all_fields = "camelCase"`: utoipa does not read
+`rename_all_fields`, so the explicit form keeps the generated schema and
+the serialized wire format in agreement.
+ */
+export type FoldedAuthorDto = FoldedAuthorDtoOneOf | FoldedAuthorDtoOneOfFour;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedAuthorDtoOneOf.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedAuthorDtoOneOf.ts
new file mode 100644
index 00000000000..5853cf6ec42
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedAuthorDtoOneOf.ts
@@ -0,0 +1,17 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+import type { FoldedAuthorDtoOneOfKind } from './foldedAuthorDtoOneOfKind';
+import type { FoldedAuthorDtoOneOfUserId } from './foldedAuthorDtoOneOfUserId';
+
+/**
+ * A person, via `session/prompt`.
+ */
+export type FoldedAuthorDtoOneOf = {
+ kind: FoldedAuthorDtoOneOfKind;
+ /** The user's macro id, absent when the prompt was unattributed. */
+ userId?: FoldedAuthorDtoOneOfUserId;
+};
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedAuthorDtoOneOfFour.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedAuthorDtoOneOfFour.ts
new file mode 100644
index 00000000000..0c9991d7355
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedAuthorDtoOneOfFour.ts
@@ -0,0 +1,14 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+import type { FoldedAuthorDtoOneOfFourKind } from './foldedAuthorDtoOneOfFourKind';
+
+/**
+ * The agent.
+ */
+export type FoldedAuthorDtoOneOfFour = {
+ kind: FoldedAuthorDtoOneOfFourKind;
+};
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedAuthorDtoOneOfFourKind.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedAuthorDtoOneOfFourKind.ts
new file mode 100644
index 00000000000..f7b657ec03a
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedAuthorDtoOneOfFourKind.ts
@@ -0,0 +1,14 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+export type FoldedAuthorDtoOneOfFourKind =
+ (typeof FoldedAuthorDtoOneOfFourKind)[keyof typeof FoldedAuthorDtoOneOfFourKind];
+
+// eslint-disable-next-line @typescript-eslint/no-redeclare
+export const FoldedAuthorDtoOneOfFourKind = {
+ agent: 'agent',
+} as const;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedAuthorDtoOneOfKind.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedAuthorDtoOneOfKind.ts
new file mode 100644
index 00000000000..8cb45d9d55b
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedAuthorDtoOneOfKind.ts
@@ -0,0 +1,14 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+export type FoldedAuthorDtoOneOfKind =
+ (typeof FoldedAuthorDtoOneOfKind)[keyof typeof FoldedAuthorDtoOneOfKind];
+
+// eslint-disable-next-line @typescript-eslint/no-redeclare
+export const FoldedAuthorDtoOneOfKind = {
+ user: 'user',
+} as const;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedAuthorDtoOneOfUserId.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedAuthorDtoOneOfUserId.ts
new file mode 100644
index 00000000000..d3857d728fc
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedAuthorDtoOneOfUserId.ts
@@ -0,0 +1,11 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+/**
+ * The user's macro id, absent when the prompt was unattributed.
+ */
+export type FoldedAuthorDtoOneOfUserId = string | null;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessageDto.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessageDto.ts
new file mode 100644
index 00000000000..1d407d2d178
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessageDto.ts
@@ -0,0 +1,31 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+import type { FoldedAuthorDto } from './foldedAuthorDto';
+import type { FoldedMessageDtoStop } from './foldedMessageDtoStop';
+import type { FoldedMessagePartDto } from './foldedMessagePartDto';
+
+/**
+ * One renderable message folded from a session's protocol log, mirroring
+[`FoldedMessage`].
+ */
+export interface FoldedMessageDto {
+ /** The composite id the placeholder comms message for this folded message
+carries in its `agent_session_message_id`:
+`"{agent_session_id}:{turn}:{author}"`. Readers join folded messages
+onto placeholder rows by this, one to one. */
+ agentSessionMessageId: string;
+ /** Who produced the message. */
+ author: FoldedAuthorDto;
+ /** Ordered renderable content. Never empty. */
+ parts: FoldedMessagePartDto[];
+ stop?: FoldedMessageDtoStop;
+ /**
+ * The turn within the session, assigned in log order from zero.
+ * @minimum 0
+ */
+ turn: number;
+}
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessageDtoStop.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessageDtoStop.ts
new file mode 100644
index 00000000000..9571c6cdb58
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessageDtoStop.ts
@@ -0,0 +1,9 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+import type { StopReasonDto } from './stopReasonDto';
+
+export type FoldedMessageDtoStop = null | StopReasonDto;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDto.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDto.ts
new file mode 100644
index 00000000000..30a8da7f2cd
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDto.ts
@@ -0,0 +1,19 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+import type { FoldedMessagePartDtoOneOf } from './foldedMessagePartDtoOneOf';
+import type { FoldedMessagePartDtoOneOfFive } from './foldedMessagePartDtoOneOfFive';
+import type { FoldedMessagePartDtoOneOfSeven } from './foldedMessagePartDtoOneOfSeven';
+import type { FoldedMessagePartDtoOneOfThree } from './foldedMessagePartDtoOneOfThree';
+
+/**
+ * A unit of renderable content, mirroring [`MessagePart`].
+ */
+export type FoldedMessagePartDto =
+ | FoldedMessagePartDtoOneOf
+ | FoldedMessagePartDtoOneOfThree
+ | FoldedMessagePartDtoOneOfFive
+ | FoldedMessagePartDtoOneOfSeven;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOf.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOf.ts
new file mode 100644
index 00000000000..5965459137e
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOf.ts
@@ -0,0 +1,16 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+import type { FoldedMessagePartDtoOneOfKind } from './foldedMessagePartDtoOneOfKind';
+
+/**
+ * Prose from the user or the agent.
+ */
+export type FoldedMessagePartDtoOneOf = {
+ kind: FoldedMessagePartDtoOneOfKind;
+ /** The prose. */
+ text: string;
+};
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfFive.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfFive.ts
new file mode 100644
index 00000000000..de152d05a9d
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfFive.ts
@@ -0,0 +1,25 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+import type { FoldedMessagePartDtoOneOfFiveKind } from './foldedMessagePartDtoOneOfFiveKind';
+import type { ToolDetailDto } from './toolDetailDto';
+import type { ToolStatusDto } from './toolStatusDto';
+
+/**
+ * A tool the agent invoked.
+ */
+export type FoldedMessagePartDtoOneOfFive = {
+ /** What the tool did, as far as the log reveals. */
+ detail: ToolDetailDto;
+ /** The ACP `toolCallId`. */
+ id: string;
+ kind: FoldedMessagePartDtoOneOfFiveKind;
+ /** What to show as the tool's name. */
+ label: string;
+ /** Where the call got to. */
+ status: ToolStatusDto;
+};
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfFiveKind.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfFiveKind.ts
new file mode 100644
index 00000000000..a0a9e9c5523
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfFiveKind.ts
@@ -0,0 +1,14 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+export type FoldedMessagePartDtoOneOfFiveKind =
+ (typeof FoldedMessagePartDtoOneOfFiveKind)[keyof typeof FoldedMessagePartDtoOneOfFiveKind];
+
+// eslint-disable-next-line @typescript-eslint/no-redeclare
+export const FoldedMessagePartDtoOneOfFiveKind = {
+ tool_use: 'tool_use',
+} as const;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfKind.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfKind.ts
new file mode 100644
index 00000000000..e1bcc05e42b
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfKind.ts
@@ -0,0 +1,14 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+export type FoldedMessagePartDtoOneOfKind =
+ (typeof FoldedMessagePartDtoOneOfKind)[keyof typeof FoldedMessagePartDtoOneOfKind];
+
+// eslint-disable-next-line @typescript-eslint/no-redeclare
+export const FoldedMessagePartDtoOneOfKind = {
+ text: 'text',
+} as const;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfSeven.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfSeven.ts
new file mode 100644
index 00000000000..f22ce2b326f
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfSeven.ts
@@ -0,0 +1,21 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+import type { FoldedMessagePartDtoOneOfSevenKind } from './foldedMessagePartDtoOneOfSevenKind';
+import type { FoldedMessagePartDtoOneOfSevenOutcome } from './foldedMessagePartDtoOneOfSevenOutcome';
+import type { PermissionOptionDto } from './permissionOptionDto';
+
+/**
+ * The agent asking to proceed with a tool call.
+ */
+export type FoldedMessagePartDtoOneOfSeven = {
+ kind: FoldedMessagePartDtoOneOfSevenKind;
+ /** The choices offered, in the order ACP listed them. */
+ options: PermissionOptionDto[];
+ outcome?: FoldedMessagePartDtoOneOfSevenOutcome;
+ /** The `toolCallId` permission was requested for. */
+ toolCall: string;
+};
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfSevenKind.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfSevenKind.ts
new file mode 100644
index 00000000000..e85150fb13e
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfSevenKind.ts
@@ -0,0 +1,14 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+export type FoldedMessagePartDtoOneOfSevenKind =
+ (typeof FoldedMessagePartDtoOneOfSevenKind)[keyof typeof FoldedMessagePartDtoOneOfSevenKind];
+
+// eslint-disable-next-line @typescript-eslint/no-redeclare
+export const FoldedMessagePartDtoOneOfSevenKind = {
+ permission: 'permission',
+} as const;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfSevenOutcome.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfSevenOutcome.ts
new file mode 100644
index 00000000000..41f307830d0
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfSevenOutcome.ts
@@ -0,0 +1,9 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+import type { PermissionOutcomeDto } from './permissionOutcomeDto';
+
+export type FoldedMessagePartDtoOneOfSevenOutcome = null | PermissionOutcomeDto;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfThree.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfThree.ts
new file mode 100644
index 00000000000..2c27650903f
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfThree.ts
@@ -0,0 +1,16 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+import type { FoldedMessagePartDtoOneOfThreeKind } from './foldedMessagePartDtoOneOfThreeKind';
+
+/**
+ * The agent's reasoning, which a reader may want to hide by default.
+ */
+export type FoldedMessagePartDtoOneOfThree = {
+ kind: FoldedMessagePartDtoOneOfThreeKind;
+ /** The reasoning. */
+ text: string;
+};
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfThreeKind.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfThreeKind.ts
new file mode 100644
index 00000000000..758e1241ead
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/foldedMessagePartDtoOneOfThreeKind.ts
@@ -0,0 +1,14 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+export type FoldedMessagePartDtoOneOfThreeKind =
+ (typeof FoldedMessagePartDtoOneOfThreeKind)[keyof typeof FoldedMessagePartDtoOneOfThreeKind];
+
+// eslint-disable-next-line @typescript-eslint/no-redeclare
+export const FoldedMessagePartDtoOneOfThreeKind = {
+ thought: 'thought',
+} as const;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/index.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/index.ts
index d5fad800cfa..a253751cc44 100644
--- a/apps/web/src/lib/service-clients/service-storage/generated/schemas/index.ts
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/index.ts
@@ -14,6 +14,7 @@ export * from './addFavoriteRequest';
export * from './addFavoriteRequestEntityType';
export * from './addParticipantsRequest';
export * from './addPinRequest';
+export * from './agentChannelMessagesResponse';
export * from './agentSessionResponse';
export * from './agentSessionResponseAcpSessionId';
export * from './agentSessionResponseOriginatingMessageId';
@@ -52,6 +53,7 @@ export * from './apiActivityInteractedAt';
export * from './apiActivityViewedAt';
export * from './apiAttachmentChannelReference';
export * from './apiAttachmentChannelReferenceChannelName';
+export * from './apiAttachmentChannelReferenceMessageContent';
export * from './apiAttachmentChannelReferenceThreadId';
export * from './apiAttachmentEntityReference';
export * from './apiAttachmentEntityReferenceOneOf';
@@ -68,11 +70,15 @@ export * from './apiChannelAttachmentsPage';
export * from './apiChannelAttachmentsPageNextCursor';
export * from './apiChannelAttachmentWidth';
export * from './apiChannelContextMessage';
+export * from './apiChannelContextMessageAgentSessionMessageId';
+export * from './apiChannelContextMessageContent';
export * from './apiChannelContextMessageDeletedAt';
export * from './apiChannelContextMessageEditedAt';
export * from './apiChannelContextMessageThreadId';
export * from './apiChannelDetail';
+export * from './apiChannelKind';
export * from './apiChannelListMessage';
+export * from './apiChannelListMessageContent';
export * from './apiChannelListMessageDeletedAt';
export * from './apiChannelListMessageThreadId';
export * from './apiChannelListPage';
@@ -81,6 +87,8 @@ export * from './apiChannelListParticipant';
export * from './apiChannelListParticipantLeftAt';
export * from './apiChannelListType';
export * from './apiChannelMessage';
+export * from './apiChannelMessageAgentSessionMessageId';
+export * from './apiChannelMessageContent';
export * from './apiChannelMessageDeletedAt';
export * from './apiChannelMessageEditedAt';
export * from './apiChannelMessageKind';
@@ -121,6 +129,8 @@ export * from './apiResolvedChannelMessage';
export * from './apiThreadInfo';
export * from './apiThreadInfoLatestReplyAt';
export * from './apiThreadReply';
+export * from './apiThreadReplyAgentSessionMessageId';
+export * from './apiThreadReplyContent';
export * from './apiThreadReplyEditedAt';
export * from './attendeeResponseStatus';
export * from './basicDocument';
@@ -227,6 +237,7 @@ export * from './channelFiltersOrgId';
export * from './channelFiltersTeamId';
export * from './channelJoinCodeResponse';
export * from './channelMessage';
+export * from './channelMessageContent';
export * from './channelMessageDeletedAt';
export * from './channelMessageFilters';
export * from './channelMessageFiltersActivityAfter';
@@ -580,6 +591,8 @@ export * from './favoriteEntityRefEntityType';
export * from './favoriteEntityType';
export * from './favoriteFileType';
export * from './favoritesList';
+export * from './fileDiffDto';
+export * from './fileDiffDtoOldText';
export * from './fileSystemNodeWithIds';
export * from './fileSystemNodeWithIdsOneOf';
export * from './fileSystemNodeWithIdsOneOfThree';
@@ -589,6 +602,24 @@ export * from './fileSystemNodeWithIdsOneOfType';
export * from './fileType';
export * from './fileTypeUpdate';
export * from './fileTypeUpdateOneOf';
+export * from './foldedAuthorDto';
+export * from './foldedAuthorDtoOneOf';
+export * from './foldedAuthorDtoOneOfFour';
+export * from './foldedAuthorDtoOneOfFourKind';
+export * from './foldedAuthorDtoOneOfKind';
+export * from './foldedAuthorDtoOneOfUserId';
+export * from './foldedMessageDto';
+export * from './foldedMessageDtoStop';
+export * from './foldedMessagePartDto';
+export * from './foldedMessagePartDtoOneOf';
+export * from './foldedMessagePartDtoOneOfFive';
+export * from './foldedMessagePartDtoOneOfFiveKind';
+export * from './foldedMessagePartDtoOneOfKind';
+export * from './foldedMessagePartDtoOneOfSeven';
+export * from './foldedMessagePartDtoOneOfSevenKind';
+export * from './foldedMessagePartDtoOneOfSevenOutcome';
+export * from './foldedMessagePartDtoOneOfThree';
+export * from './foldedMessagePartDtoOneOfThreeKind';
export * from './folderItem';
export * from './folderItemFileType';
export * from './foreignEntity';
@@ -791,6 +822,12 @@ export * from './pdfHighlightRectAnchorRequest';
export * from './pdfPlaceableCommentAnchor';
export * from './pdfPlaceableCommentAnchorRequest';
export * from './pdfPlaceableCommentAnchorRequestUuid';
+export * from './permissionOptionDto';
+export * from './permissionOutcomeDto';
+export * from './permissionOutcomeDtoOneOf';
+export * from './permissionOutcomeDtoOneOfKind';
+export * from './permissionOutcomeDtoOneOfThree';
+export * from './permissionOutcomeDtoOneOfThreeKind';
export * from './pinnedItem';
export * from './pinRequest';
export * from './postActivityRequest';
@@ -966,6 +1003,7 @@ export * from './soupChannelAllOf';
export * from './soupChannelAllOfInteractedAt';
export * from './soupChannelAllOfViewedAt';
export * from './soupChannelThread';
+export * from './soupChannelThreadContent';
export * from './soupChannelThreadDeletedAt';
export * from './soupChannelThreadEditedAt';
export * from './soupChatSoupPropertiesField';
@@ -1063,8 +1101,22 @@ export * from './soupPropertyValue';
export * from './soupThreadInfo';
export * from './soupThreadInfoLatestReplyAt';
export * from './soupThreadReply';
+export * from './soupThreadReplyContent';
export * from './soupThreadReplyEditedAt';
export * from './starterDocumentsResponse';
+export * from './stopReasonDto';
+export * from './stopReasonDtoOneOf';
+export * from './stopReasonDtoOneOfFive';
+export * from './stopReasonDtoOneOfFiveKind';
+export * from './stopReasonDtoOneOfKind';
+export * from './stopReasonDtoOneOfNine';
+export * from './stopReasonDtoOneOfNineKind';
+export * from './stopReasonDtoOneOfOneone';
+export * from './stopReasonDtoOneOfOneoneKind';
+export * from './stopReasonDtoOneOfSeven';
+export * from './stopReasonDtoOneOfSevenKind';
+export * from './stopReasonDtoOneOfThree';
+export * from './stopReasonDtoOneOfThreeKind';
export * from './string';
export * from './successResponse';
export * from './syncServiceVersionID';
@@ -1077,6 +1129,21 @@ export * from './threadCreatedAt';
export * from './threadDeletedAt';
export * from './threadResponse';
export * from './threadUpdatedAt';
+export * from './toolDetailDto';
+export * from './toolDetailDtoOneOf';
+export * from './toolDetailDtoOneOfCommand';
+export * from './toolDetailDtoOneOfEight';
+export * from './toolDetailDtoOneOfEightKind';
+export * from './toolDetailDtoOneOfExitCode';
+export * from './toolDetailDtoOneOfKind';
+export * from './toolDetailDtoOneOfOnezero';
+export * from './toolDetailDtoOneOfOnezeroInput';
+export * from './toolDetailDtoOneOfOnezeroInputAnyOf';
+export * from './toolDetailDtoOneOfOnezeroKind';
+export * from './toolDetailDtoOneOfOutput';
+export * from './toolDetailDtoOneOfSix';
+export * from './toolDetailDtoOneOfSixKind';
+export * from './toolStatusDto';
export * from './transcriptSegmentRequest';
export * from './transcriptSegmentRequestDiarizedSpeakerId';
export * from './transcriptSegmentRequestEmbedding';
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/permissionOptionDto.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/permissionOptionDto.ts
new file mode 100644
index 00000000000..b9388f3e690
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/permissionOptionDto.ts
@@ -0,0 +1,20 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+/**
+ * One choice offered for a permission request, mirroring
+[`PermissionOption`].
+ */
+export interface PermissionOptionDto {
+ /** The id to report back when this option is chosen. */
+ id: string;
+ /** ACP's option kind, as its wire string - `allow_once`, `reject_once`,
+`allow_always`, `reject_always`. */
+ kind: string;
+ /** Label to show. */
+ name: string;
+}
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/permissionOutcomeDto.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/permissionOutcomeDto.ts
new file mode 100644
index 00000000000..a10b2571463
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/permissionOutcomeDto.ts
@@ -0,0 +1,15 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+import type { PermissionOutcomeDtoOneOf } from './permissionOutcomeDtoOneOf';
+import type { PermissionOutcomeDtoOneOfThree } from './permissionOutcomeDtoOneOfThree';
+
+/**
+ * How a permission request resolved, mirroring [`PermissionOutcome`].
+ */
+export type PermissionOutcomeDto =
+ | PermissionOutcomeDtoOneOf
+ | PermissionOutcomeDtoOneOfThree;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/permissionOutcomeDtoOneOf.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/permissionOutcomeDtoOneOf.ts
new file mode 100644
index 00000000000..3c73430a0a3
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/permissionOutcomeDtoOneOf.ts
@@ -0,0 +1,16 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+import type { PermissionOutcomeDtoOneOfKind } from './permissionOutcomeDtoOneOfKind';
+
+/**
+ * An option was chosen.
+ */
+export type PermissionOutcomeDtoOneOf = {
+ kind: PermissionOutcomeDtoOneOfKind;
+ /** The chosen option's id. */
+ optionId: string;
+};
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/permissionOutcomeDtoOneOfKind.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/permissionOutcomeDtoOneOfKind.ts
new file mode 100644
index 00000000000..06b3e070c6a
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/permissionOutcomeDtoOneOfKind.ts
@@ -0,0 +1,14 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+export type PermissionOutcomeDtoOneOfKind =
+ (typeof PermissionOutcomeDtoOneOfKind)[keyof typeof PermissionOutcomeDtoOneOfKind];
+
+// eslint-disable-next-line @typescript-eslint/no-redeclare
+export const PermissionOutcomeDtoOneOfKind = {
+ selected: 'selected',
+} as const;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/permissionOutcomeDtoOneOfThree.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/permissionOutcomeDtoOneOfThree.ts
new file mode 100644
index 00000000000..4c8bcda326d
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/permissionOutcomeDtoOneOfThree.ts
@@ -0,0 +1,14 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+import type { PermissionOutcomeDtoOneOfThreeKind } from './permissionOutcomeDtoOneOfThreeKind';
+
+/**
+ * The request was cancelled without a choice.
+ */
+export type PermissionOutcomeDtoOneOfThree = {
+ kind: PermissionOutcomeDtoOneOfThreeKind;
+};
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/permissionOutcomeDtoOneOfThreeKind.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/permissionOutcomeDtoOneOfThreeKind.ts
new file mode 100644
index 00000000000..8e534d99a1c
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/permissionOutcomeDtoOneOfThreeKind.ts
@@ -0,0 +1,14 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+export type PermissionOutcomeDtoOneOfThreeKind =
+ (typeof PermissionOutcomeDtoOneOfThreeKind)[keyof typeof PermissionOutcomeDtoOneOfThreeKind];
+
+// eslint-disable-next-line @typescript-eslint/no-redeclare
+export const PermissionOutcomeDtoOneOfThreeKind = {
+ cancelled: 'cancelled',
+} as const;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/soupChannelThread.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/soupChannelThread.ts
index b6ebf8e6804..e8de709700f 100644
--- a/apps/web/src/lib/service-clients/service-storage/generated/schemas/soupChannelThread.ts
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/soupChannelThread.ts
@@ -5,6 +5,7 @@
* OpenAPI spec version: 0.1.0
*/
+import type { SoupChannelThreadContent } from './soupChannelThreadContent';
import type { SoupChannelThreadDeletedAt } from './soupChannelThreadDeletedAt';
import type { SoupChannelThreadEditedAt } from './soupChannelThreadEditedAt';
import type { SoupCountedReaction } from './soupCountedReaction';
@@ -23,8 +24,8 @@ export interface SoupChannelThread {
attachments: SoupMessageAttachment[];
/** Channel id. */
channel_id: string;
- /** Message content. */
- content: string;
+ /** Message content. `None` on agent-turn placeholder messages. */
+ content?: SoupChannelThreadContent;
/** Creation timestamp. */
created_at: string;
/** Deletion timestamp. */
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/soupChannelThreadContent.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/soupChannelThreadContent.ts
new file mode 100644
index 00000000000..8673d2a0a36
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/soupChannelThreadContent.ts
@@ -0,0 +1,11 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+/**
+ * Message content. `None` on agent-turn placeholder messages.
+ */
+export type SoupChannelThreadContent = string | null;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/soupThreadReply.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/soupThreadReply.ts
index 318f8b511ad..13ab9719e92 100644
--- a/apps/web/src/lib/service-clients/service-storage/generated/schemas/soupThreadReply.ts
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/soupThreadReply.ts
@@ -8,6 +8,7 @@
import type { SoupCountedReaction } from './soupCountedReaction';
import type { SoupMessageAttachment } from './soupMessageAttachment';
import type { SoupMessageSender } from './soupMessageSender';
+import type { SoupThreadReplyContent } from './soupThreadReplyContent';
import type { SoupThreadReplyEditedAt } from './soupThreadReplyEditedAt';
/**
@@ -16,8 +17,8 @@ import type { SoupThreadReplyEditedAt } from './soupThreadReplyEditedAt';
export interface SoupThreadReply {
/** Attachments on this reply. */
attachments: SoupMessageAttachment[];
- /** Reply content. */
- content: string;
+ /** Reply content. `None` on agent-turn placeholder messages. */
+ content?: SoupThreadReplyContent;
/** Creation timestamp. */
created_at: string;
/** Edit timestamp. */
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/soupThreadReplyContent.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/soupThreadReplyContent.ts
new file mode 100644
index 00000000000..03d539969aa
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/soupThreadReplyContent.ts
@@ -0,0 +1,11 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+/**
+ * Reply content. `None` on agent-turn placeholder messages.
+ */
+export type SoupThreadReplyContent = string | null;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDto.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDto.ts
new file mode 100644
index 00000000000..e081ab0717f
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDto.ts
@@ -0,0 +1,23 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+import type { StopReasonDtoOneOf } from './stopReasonDtoOneOf';
+import type { StopReasonDtoOneOfFive } from './stopReasonDtoOneOfFive';
+import type { StopReasonDtoOneOfNine } from './stopReasonDtoOneOfNine';
+import type { StopReasonDtoOneOfOneone } from './stopReasonDtoOneOfOneone';
+import type { StopReasonDtoOneOfSeven } from './stopReasonDtoOneOfSeven';
+import type { StopReasonDtoOneOfThree } from './stopReasonDtoOneOfThree';
+
+/**
+ * Why a turn stopped, mirroring [`StopReason`].
+ */
+export type StopReasonDto =
+ | StopReasonDtoOneOf
+ | StopReasonDtoOneOfThree
+ | StopReasonDtoOneOfFive
+ | StopReasonDtoOneOfSeven
+ | StopReasonDtoOneOfNine
+ | StopReasonDtoOneOfOneone;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOf.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOf.ts
new file mode 100644
index 00000000000..753d9662ff9
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOf.ts
@@ -0,0 +1,14 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+import type { StopReasonDtoOneOfKind } from './stopReasonDtoOneOfKind';
+
+/**
+ * The agent finished its turn.
+ */
+export type StopReasonDtoOneOf = {
+ kind: StopReasonDtoOneOfKind;
+};
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfFive.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfFive.ts
new file mode 100644
index 00000000000..b8ca4d3b540
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfFive.ts
@@ -0,0 +1,14 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+import type { StopReasonDtoOneOfFiveKind } from './stopReasonDtoOneOfFiveKind';
+
+/**
+ * The agent hit its turn-request limit.
+ */
+export type StopReasonDtoOneOfFive = {
+ kind: StopReasonDtoOneOfFiveKind;
+};
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfFiveKind.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfFiveKind.ts
new file mode 100644
index 00000000000..078f0478d27
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfFiveKind.ts
@@ -0,0 +1,14 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+export type StopReasonDtoOneOfFiveKind =
+ (typeof StopReasonDtoOneOfFiveKind)[keyof typeof StopReasonDtoOneOfFiveKind];
+
+// eslint-disable-next-line @typescript-eslint/no-redeclare
+export const StopReasonDtoOneOfFiveKind = {
+ max_turn_requests: 'max_turn_requests',
+} as const;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfKind.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfKind.ts
new file mode 100644
index 00000000000..b2623232238
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfKind.ts
@@ -0,0 +1,14 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+export type StopReasonDtoOneOfKind =
+ (typeof StopReasonDtoOneOfKind)[keyof typeof StopReasonDtoOneOfKind];
+
+// eslint-disable-next-line @typescript-eslint/no-redeclare
+export const StopReasonDtoOneOfKind = {
+ end_turn: 'end_turn',
+} as const;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfNine.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfNine.ts
new file mode 100644
index 00000000000..7855c31096b
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfNine.ts
@@ -0,0 +1,14 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+import type { StopReasonDtoOneOfNineKind } from './stopReasonDtoOneOfNineKind';
+
+/**
+ * The turn was cancelled.
+ */
+export type StopReasonDtoOneOfNine = {
+ kind: StopReasonDtoOneOfNineKind;
+};
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfNineKind.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfNineKind.ts
new file mode 100644
index 00000000000..b93732bfce7
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfNineKind.ts
@@ -0,0 +1,14 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+export type StopReasonDtoOneOfNineKind =
+ (typeof StopReasonDtoOneOfNineKind)[keyof typeof StopReasonDtoOneOfNineKind];
+
+// eslint-disable-next-line @typescript-eslint/no-redeclare
+export const StopReasonDtoOneOfNineKind = {
+ cancelled: 'cancelled',
+} as const;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfOneone.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfOneone.ts
new file mode 100644
index 00000000000..97650f88b44
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfOneone.ts
@@ -0,0 +1,16 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+import type { StopReasonDtoOneOfOneoneKind } from './stopReasonDtoOneOfOneoneKind';
+
+/**
+ * A stop reason the fold does not model.
+ */
+export type StopReasonDtoOneOfOneone = {
+ kind: StopReasonDtoOneOfOneoneKind;
+ /** The wire string. */
+ reason: string;
+};
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfOneoneKind.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfOneoneKind.ts
new file mode 100644
index 00000000000..f47e07981ad
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfOneoneKind.ts
@@ -0,0 +1,14 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+export type StopReasonDtoOneOfOneoneKind =
+ (typeof StopReasonDtoOneOfOneoneKind)[keyof typeof StopReasonDtoOneOfOneoneKind];
+
+// eslint-disable-next-line @typescript-eslint/no-redeclare
+export const StopReasonDtoOneOfOneoneKind = {
+ other: 'other',
+} as const;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfSeven.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfSeven.ts
new file mode 100644
index 00000000000..cee15892939
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfSeven.ts
@@ -0,0 +1,14 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+import type { StopReasonDtoOneOfSevenKind } from './stopReasonDtoOneOfSevenKind';
+
+/**
+ * The agent declined.
+ */
+export type StopReasonDtoOneOfSeven = {
+ kind: StopReasonDtoOneOfSevenKind;
+};
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfSevenKind.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfSevenKind.ts
new file mode 100644
index 00000000000..eaf7d669a1d
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfSevenKind.ts
@@ -0,0 +1,14 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+export type StopReasonDtoOneOfSevenKind =
+ (typeof StopReasonDtoOneOfSevenKind)[keyof typeof StopReasonDtoOneOfSevenKind];
+
+// eslint-disable-next-line @typescript-eslint/no-redeclare
+export const StopReasonDtoOneOfSevenKind = {
+ refusal: 'refusal',
+} as const;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfThree.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfThree.ts
new file mode 100644
index 00000000000..0ba92e90099
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfThree.ts
@@ -0,0 +1,14 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+import type { StopReasonDtoOneOfThreeKind } from './stopReasonDtoOneOfThreeKind';
+
+/**
+ * The model hit its token limit.
+ */
+export type StopReasonDtoOneOfThree = {
+ kind: StopReasonDtoOneOfThreeKind;
+};
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfThreeKind.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfThreeKind.ts
new file mode 100644
index 00000000000..c2a99cb0d45
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/stopReasonDtoOneOfThreeKind.ts
@@ -0,0 +1,14 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+export type StopReasonDtoOneOfThreeKind =
+ (typeof StopReasonDtoOneOfThreeKind)[keyof typeof StopReasonDtoOneOfThreeKind];
+
+// eslint-disable-next-line @typescript-eslint/no-redeclare
+export const StopReasonDtoOneOfThreeKind = {
+ max_tokens: 'max_tokens',
+} as const;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDto.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDto.ts
new file mode 100644
index 00000000000..30edcf80357
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDto.ts
@@ -0,0 +1,19 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+import type { ToolDetailDtoOneOf } from './toolDetailDtoOneOf';
+import type { ToolDetailDtoOneOfEight } from './toolDetailDtoOneOfEight';
+import type { ToolDetailDtoOneOfOnezero } from './toolDetailDtoOneOfOnezero';
+import type { ToolDetailDtoOneOfSix } from './toolDetailDtoOneOfSix';
+
+/**
+ * What a tool call actually did, mirroring [`ToolDetail`].
+ */
+export type ToolDetailDto =
+ | ToolDetailDtoOneOf
+ | ToolDetailDtoOneOfSix
+ | ToolDetailDtoOneOfEight
+ | ToolDetailDtoOneOfOnezero;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOf.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOf.ts
new file mode 100644
index 00000000000..6dfee6b5c5c
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOf.ts
@@ -0,0 +1,23 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+import type { ToolDetailDtoOneOfCommand } from './toolDetailDtoOneOfCommand';
+import type { ToolDetailDtoOneOfExitCode } from './toolDetailDtoOneOfExitCode';
+import type { ToolDetailDtoOneOfKind } from './toolDetailDtoOneOfKind';
+import type { ToolDetailDtoOneOfOutput } from './toolDetailDtoOneOfOutput';
+
+/**
+ * A shell command.
+ */
+export type ToolDetailDtoOneOf = {
+ /** The command line, when the harness reported one. */
+ command?: ToolDetailDtoOneOfCommand;
+ /** Process exit code, when the harness reported one. */
+ exitCode?: ToolDetailDtoOneOfExitCode;
+ kind: ToolDetailDtoOneOfKind;
+ /** Captured output, ANSI escape sequences left in place. */
+ output?: ToolDetailDtoOneOfOutput;
+};
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfCommand.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfCommand.ts
new file mode 100644
index 00000000000..70b47828a11
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfCommand.ts
@@ -0,0 +1,11 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+/**
+ * The command line, when the harness reported one.
+ */
+export type ToolDetailDtoOneOfCommand = string | null;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfEight.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfEight.ts
new file mode 100644
index 00000000000..4224a416964
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfEight.ts
@@ -0,0 +1,16 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+import type { ToolDetailDtoOneOfEightKind } from './toolDetailDtoOneOfEightKind';
+
+/**
+ * A file read.
+ */
+export type ToolDetailDtoOneOfEight = {
+ kind: ToolDetailDtoOneOfEightKind;
+ /** Paths this call touched. */
+ paths: string[];
+};
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfEightKind.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfEightKind.ts
new file mode 100644
index 00000000000..6ed0db2c11b
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfEightKind.ts
@@ -0,0 +1,14 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+export type ToolDetailDtoOneOfEightKind =
+ (typeof ToolDetailDtoOneOfEightKind)[keyof typeof ToolDetailDtoOneOfEightKind];
+
+// eslint-disable-next-line @typescript-eslint/no-redeclare
+export const ToolDetailDtoOneOfEightKind = {
+ read: 'read',
+} as const;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfExitCode.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfExitCode.ts
new file mode 100644
index 00000000000..7f4b6041d22
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfExitCode.ts
@@ -0,0 +1,11 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+/**
+ * Process exit code, when the harness reported one.
+ */
+export type ToolDetailDtoOneOfExitCode = number | null;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfKind.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfKind.ts
new file mode 100644
index 00000000000..bffcf938ff7
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfKind.ts
@@ -0,0 +1,14 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+export type ToolDetailDtoOneOfKind =
+ (typeof ToolDetailDtoOneOfKind)[keyof typeof ToolDetailDtoOneOfKind];
+
+// eslint-disable-next-line @typescript-eslint/no-redeclare
+export const ToolDetailDtoOneOfKind = {
+ terminal: 'terminal',
+} as const;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfOnezero.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfOnezero.ts
new file mode 100644
index 00000000000..279f3f656ec
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfOnezero.ts
@@ -0,0 +1,19 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+import type { ToolDetailDtoOneOfOnezeroInput } from './toolDetailDtoOneOfOnezeroInput';
+import type { ToolDetailDtoOneOfOnezeroKind } from './toolDetailDtoOneOfOnezeroKind';
+
+/**
+ * Anything else, including tools the fold has no special rendering for.
+ */
+export type ToolDetailDtoOneOfOnezero = {
+ /** ACP's tool kind, as its wire string. */
+ acpKind: string;
+ /** The tool's input, when reported. */
+ input?: ToolDetailDtoOneOfOnezeroInput;
+ kind: ToolDetailDtoOneOfOnezeroKind;
+};
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfOnezeroInput.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfOnezeroInput.ts
new file mode 100644
index 00000000000..a91a3083c43
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfOnezeroInput.ts
@@ -0,0 +1,13 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+import type { ToolDetailDtoOneOfOnezeroInputAnyOf } from './toolDetailDtoOneOfOnezeroInputAnyOf';
+
+/**
+ * The tool's input, when reported.
+ */
+export type ToolDetailDtoOneOfOnezeroInput =
+ ToolDetailDtoOneOfOnezeroInputAnyOf | null;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfOnezeroInputAnyOf.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfOnezeroInputAnyOf.ts
new file mode 100644
index 00000000000..2aa7c47db29
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfOnezeroInputAnyOf.ts
@@ -0,0 +1,11 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+/**
+ * The tool's input, when reported.
+ */
+export type ToolDetailDtoOneOfOnezeroInputAnyOf = { [key: string]: unknown };
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfOnezeroKind.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfOnezeroKind.ts
new file mode 100644
index 00000000000..5bc056ff40d
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfOnezeroKind.ts
@@ -0,0 +1,14 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+export type ToolDetailDtoOneOfOnezeroKind =
+ (typeof ToolDetailDtoOneOfOnezeroKind)[keyof typeof ToolDetailDtoOneOfOnezeroKind];
+
+// eslint-disable-next-line @typescript-eslint/no-redeclare
+export const ToolDetailDtoOneOfOnezeroKind = {
+ other: 'other',
+} as const;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfOutput.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfOutput.ts
new file mode 100644
index 00000000000..946e4670430
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfOutput.ts
@@ -0,0 +1,11 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+/**
+ * Captured output, ANSI escape sequences left in place.
+ */
+export type ToolDetailDtoOneOfOutput = string | null;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfSix.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfSix.ts
new file mode 100644
index 00000000000..ab866e3bbf1
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfSix.ts
@@ -0,0 +1,17 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+import type { FileDiffDto } from './fileDiffDto';
+import type { ToolDetailDtoOneOfSixKind } from './toolDetailDtoOneOfSixKind';
+
+/**
+ * One or more file modifications.
+ */
+export type ToolDetailDtoOneOfSix = {
+ /** The diffs ACP reported for this call. */
+ diffs: FileDiffDto[];
+ kind: ToolDetailDtoOneOfSixKind;
+};
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfSixKind.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfSixKind.ts
new file mode 100644
index 00000000000..c8e911ab46e
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolDetailDtoOneOfSixKind.ts
@@ -0,0 +1,14 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+export type ToolDetailDtoOneOfSixKind =
+ (typeof ToolDetailDtoOneOfSixKind)[keyof typeof ToolDetailDtoOneOfSixKind];
+
+// eslint-disable-next-line @typescript-eslint/no-redeclare
+export const ToolDetailDtoOneOfSixKind = {
+ edit: 'edit',
+} as const;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolStatusDto.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolStatusDto.ts
new file mode 100644
index 00000000000..53b57fe7cc1
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/toolStatusDto.ts
@@ -0,0 +1,19 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * document_storage_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+/**
+ * How far a tool call progressed, mirroring [`ToolStatus`].
+ */
+export type ToolStatusDto = (typeof ToolStatusDto)[keyof typeof ToolStatusDto];
+
+// eslint-disable-next-line @typescript-eslint/no-redeclare
+export const ToolStatusDto = {
+ pending: 'pending',
+ running: 'running',
+ completed: 'completed',
+ failed: 'failed',
+} as const;
diff --git a/apps/web/src/lib/service-clients/service-storage/generated/zod.ts b/apps/web/src/lib/service-clients/service-storage/generated/zod.ts
index bfa39de4792..a81d49460a7 100644
--- a/apps/web/src/lib/service-clients/service-storage/generated/zod.ts
+++ b/apps/web/src/lib/service-clients/service-storage/generated/zod.ts
@@ -160,6 +160,291 @@ export const getRecentActivityHandlerResponse = zod.object({
error: zod.boolean().describe('Indicates if an error occurred'),
});
+/**
+ * Placeholder comms messages in an agent channel store no body, only an
+`agent_session_message_id`; each message here carries the same composite id,
+so a reader joins the two to render the channel. `404` when no agent
+session owns the channel.
+ * @summary The folded messages of the agent session behind a channel.
+ */
+export const getAgentChannelMessagesParams = zod.object({
+ channel_id: zod.uuid().describe("ID of the session's dedicated channel"),
+});
+
+export const getAgentChannelMessagesResponseMessagesItemTurnMin = 0;
+
+export const getAgentChannelMessagesResponse = zod
+ .object({
+ agentSessionId: zod
+ .uuid()
+ .describe('The session whose log derived the messages.'),
+ messages: zod
+ .array(
+ zod
+ .object({
+ agentSessionMessageId: zod
+ .string()
+ .describe(
+ 'The composite id the placeholder comms message for this folded message\ncarries in its `agent_session_message_id`:\n`\"{agent_session_id}:{turn}:{author}\"`. Readers join folded messages\nonto placeholder rows by this, one to one.'
+ ),
+ author: zod
+ .union([
+ zod
+ .object({
+ kind: zod.enum(['user']),
+ userId: zod
+ .string()
+ .nullish()
+ .describe(
+ "The user's macro id, absent when the prompt was unattributed."
+ ),
+ })
+ .describe('A person, via `session\/prompt`.'),
+ zod
+ .object({
+ kind: zod.enum(['agent']),
+ })
+ .describe('The agent.'),
+ ])
+ .describe(
+ 'Who produced a folded message, mirroring [`Author`].\n\nMulti-word fields in these enums carry explicit `#[serde(rename)]`s\ninstead of `rename_all_fields = \"camelCase\"`: utoipa does not read\n`rename_all_fields`, so the explicit form keeps the generated schema and\nthe serialized wire format in agreement.'
+ ),
+ parts: zod
+ .array(
+ zod
+ .union([
+ zod
+ .object({
+ kind: zod.enum(['text']),
+ text: zod.string().describe('The prose.'),
+ })
+ .describe('Prose from the user or the agent.'),
+ zod
+ .object({
+ kind: zod.enum(['thought']),
+ text: zod.string().describe('The reasoning.'),
+ })
+ .describe(
+ "The agent's reasoning, which a reader may want to hide by default."
+ ),
+ zod
+ .object({
+ detail: zod
+ .union([
+ zod
+ .object({
+ command: zod
+ .string()
+ .nullish()
+ .describe(
+ 'The command line, when the harness reported one.'
+ ),
+ exitCode: zod
+ .number()
+ .nullish()
+ .describe(
+ 'Process exit code, when the harness reported one.'
+ ),
+ kind: zod.enum(['terminal']),
+ output: zod
+ .string()
+ .nullish()
+ .describe(
+ 'Captured output, ANSI escape sequences left in place.'
+ ),
+ })
+ .describe('A shell command.'),
+ zod
+ .object({
+ diffs: zod
+ .array(
+ zod
+ .object({
+ newText: zod
+ .string()
+ .describe('New contents.'),
+ oldText: zod
+ .string()
+ .nullish()
+ .describe(
+ 'Prior contents, absent when the file is new.'
+ ),
+ path: zod
+ .string()
+ .describe('The file that changed.'),
+ })
+ .describe(
+ 'A file modification a tool reported, mirroring [`FileDiff`].'
+ )
+ )
+ .describe(
+ 'The diffs ACP reported for this call.'
+ ),
+ kind: zod.enum(['edit']),
+ })
+ .describe('One or more file modifications.'),
+ zod
+ .object({
+ kind: zod.enum(['read']),
+ paths: zod
+ .array(zod.string())
+ .describe('Paths this call touched.'),
+ })
+ .describe('A file read.'),
+ zod
+ .object({
+ acpKind: zod
+ .string()
+ .describe(
+ "ACP's tool kind, as its wire string."
+ ),
+ input: zod
+ .object({})
+ .nullish()
+ .describe("The tool's input, when reported."),
+ kind: zod.enum(['other']),
+ })
+ .describe(
+ 'Anything else, including tools the fold has no special rendering for.'
+ ),
+ ])
+ .describe(
+ 'What a tool call actually did, mirroring [`ToolDetail`].'
+ ),
+ id: zod.string().describe('The ACP `toolCallId`.'),
+ kind: zod.enum(['tool_use']),
+ label: zod
+ .string()
+ .describe("What to show as the tool's name."),
+ status: zod
+ .enum(['pending', 'running', 'completed', 'failed'])
+ .describe(
+ 'How far a tool call progressed, mirroring [`ToolStatus`].'
+ ),
+ })
+ .describe('A tool the agent invoked.'),
+ zod
+ .object({
+ kind: zod.enum(['permission']),
+ options: zod
+ .array(
+ zod
+ .object({
+ id: zod
+ .string()
+ .describe(
+ 'The id to report back when this option is chosen.'
+ ),
+ kind: zod
+ .string()
+ .describe(
+ "ACP's option kind, as its wire string - `allow_once`, `reject_once`,\n`allow_always`, `reject_always`."
+ ),
+ name: zod.string().describe('Label to show.'),
+ })
+ .describe(
+ 'One choice offered for a permission request, mirroring\n[`PermissionOption`].'
+ )
+ )
+ .describe(
+ 'The choices offered, in the order ACP listed them.'
+ ),
+ outcome: zod
+ .union([
+ zod.null(),
+ zod
+ .union([
+ zod
+ .object({
+ kind: zod.enum(['selected']),
+ optionId: zod
+ .string()
+ .describe("The chosen option's id."),
+ })
+ .describe('An option was chosen.'),
+ zod
+ .object({
+ kind: zod.enum(['cancelled']),
+ })
+ .describe(
+ 'The request was cancelled without a choice.'
+ ),
+ ])
+ .describe(
+ 'How a permission request resolved, mirroring [`PermissionOutcome`].'
+ ),
+ ])
+ .optional(),
+ toolCall: zod
+ .string()
+ .describe(
+ 'The `toolCallId` permission was requested for.'
+ ),
+ })
+ .describe(
+ 'The agent asking to proceed with a tool call.'
+ ),
+ ])
+ .describe(
+ 'A unit of renderable content, mirroring [`MessagePart`].'
+ )
+ )
+ .describe('Ordered renderable content. Never empty.'),
+ stop: zod
+ .union([
+ zod.null(),
+ zod
+ .union([
+ zod
+ .object({
+ kind: zod.enum(['end_turn']),
+ })
+ .describe('The agent finished its turn.'),
+ zod
+ .object({
+ kind: zod.enum(['max_tokens']),
+ })
+ .describe('The model hit its token limit.'),
+ zod
+ .object({
+ kind: zod.enum(['max_turn_requests']),
+ })
+ .describe('The agent hit its turn-request limit.'),
+ zod
+ .object({
+ kind: zod.enum(['refusal']),
+ })
+ .describe('The agent declined.'),
+ zod
+ .object({
+ kind: zod.enum(['cancelled']),
+ })
+ .describe('The turn was cancelled.'),
+ zod
+ .object({
+ kind: zod.enum(['other']),
+ reason: zod.string().describe('The wire string.'),
+ })
+ .describe('A stop reason the fold does not model.'),
+ ])
+ .describe('Why a turn stopped, mirroring [`StopReason`].'),
+ ])
+ .optional(),
+ turn: zod
+ .number()
+ .min(getAgentChannelMessagesResponseMessagesItemTurnMin)
+ .describe(
+ 'The turn within the session, assigned in log order from zero.'
+ ),
+ })
+ .describe(
+ "One renderable message folded from a session's protocol log, mirroring\n[`FoldedMessage`]."
+ )
+ )
+ .describe("The session's folded messages, oldest first."),
+ })
+ .describe("Response body for a channel's folded agent-session messages.");
+
/**
* @summary Get an agent session by id.
*/
@@ -1861,8 +2146,9 @@ export const getAttachmentReferencesResponse = zod
.describe('Optional channel name (DMs do not have a name).'),
message_content: zod
.string()
+ .nullish()
.describe(
- 'Full message content (might be used for preview\/snippet).'
+ 'Full message content (might be used for preview\/snippet). `None` on\nagent-turn placeholder messages.'
),
message_created_at: zod.iso
.datetime({})
@@ -2096,6 +2382,12 @@ export const getChannelResponse = zod
.array(
zod
.object({
+ agent_session_message_id: zod
+ .string()
+ .nullish()
+ .describe(
+ 'The agent session turn this placeholder renders, as the composite\n`\"{agent_session_id}:{turn}\"`. Set only on agent-turn placeholders;\njoin it against the folded messages of the channel\'s agent session.'
+ ),
attachments: zod
.array(
zod
@@ -2119,7 +2411,12 @@ export const getChannelResponse = zod
)
.describe('Attachments on this message.'),
channel_id: zod.uuid().describe('Channel id.'),
- content: zod.string().describe('Message content.'),
+ content: zod
+ .string()
+ .nullish()
+ .describe(
+ 'Message content. `None` on agent-turn placeholder messages, whose body\nis folded from the agent session log and joined in by the client.'
+ ),
created_at: zod.iso
.datetime({})
.describe('When the message was created.'),
@@ -2177,6 +2474,12 @@ export const getChannelResponse = zod
.array(
zod
.object({
+ agent_session_message_id: zod
+ .string()
+ .nullish()
+ .describe(
+ 'The agent session turn this placeholder renders, as the composite\n`\"{agent_session_id}:{turn}\"`. Set only on agent-turn placeholders.'
+ ),
attachments: zod
.array(
zod
@@ -2201,7 +2504,12 @@ export const getChannelResponse = zod
.describe('An attachment on a message.')
)
.describe('Attachments on this reply.'),
- content: zod.string().describe('Reply content.'),
+ content: zod
+ .string()
+ .nullish()
+ .describe(
+ 'Reply content. `None` on agent-turn placeholder messages.'
+ ),
created_at: zod.iso
.datetime({})
.describe('When the reply was created.'),
@@ -2576,6 +2884,12 @@ export const getChannelMessagesResponse = zod
.array(
zod
.object({
+ agent_session_message_id: zod
+ .string()
+ .nullish()
+ .describe(
+ 'The agent session turn this placeholder renders, as the composite\n`\"{agent_session_id}:{turn}\"`. Set only on agent-turn placeholders;\njoin it against the folded messages of the channel\'s agent session.'
+ ),
attachments: zod
.array(
zod
@@ -2599,7 +2913,12 @@ export const getChannelMessagesResponse = zod
)
.describe('Attachments on this message.'),
channel_id: zod.uuid().describe('Channel id.'),
- content: zod.string().describe('Message content.'),
+ content: zod
+ .string()
+ .nullish()
+ .describe(
+ 'Message content. `None` on agent-turn placeholder messages, whose body\nis folded from the agent session log and joined in by the client.'
+ ),
created_at: zod.iso
.datetime({})
.describe('When the message was created.'),
@@ -2657,6 +2976,12 @@ export const getChannelMessagesResponse = zod
.array(
zod
.object({
+ agent_session_message_id: zod
+ .string()
+ .nullish()
+ .describe(
+ 'The agent session turn this placeholder renders, as the composite\n`\"{agent_session_id}:{turn}\"`. Set only on agent-turn placeholders.'
+ ),
attachments: zod
.array(
zod
@@ -2681,7 +3006,12 @@ export const getChannelMessagesResponse = zod
.describe('An attachment on a message.')
)
.describe('Attachments on this reply.'),
- content: zod.string().describe('Reply content.'),
+ content: zod
+ .string()
+ .nullish()
+ .describe(
+ 'Reply content. `None` on agent-turn placeholder messages.'
+ ),
created_at: zod.iso
.datetime({})
.describe('When the reply was created.'),
@@ -2848,6 +3178,12 @@ export const postChannelMessagesResponse = zod
.array(
zod
.object({
+ agent_session_message_id: zod
+ .string()
+ .nullish()
+ .describe(
+ 'The agent session turn this placeholder renders, as the composite\n`\"{agent_session_id}:{turn}\"`. Set only on agent-turn placeholders;\njoin it against the folded messages of the channel\'s agent session.'
+ ),
attachments: zod
.array(
zod
@@ -2871,7 +3207,12 @@ export const postChannelMessagesResponse = zod
)
.describe('Attachments on this message.'),
channel_id: zod.uuid().describe('Channel id.'),
- content: zod.string().describe('Message content.'),
+ content: zod
+ .string()
+ .nullish()
+ .describe(
+ 'Message content. `None` on agent-turn placeholder messages, whose body\nis folded from the agent session log and joined in by the client.'
+ ),
created_at: zod.iso
.datetime({})
.describe('When the message was created.'),
@@ -2929,6 +3270,12 @@ export const postChannelMessagesResponse = zod
.array(
zod
.object({
+ agent_session_message_id: zod
+ .string()
+ .nullish()
+ .describe(
+ 'The agent session turn this placeholder renders, as the composite\n`\"{agent_session_id}:{turn}\"`. Set only on agent-turn placeholders.'
+ ),
attachments: zod
.array(
zod
@@ -2953,7 +3300,12 @@ export const postChannelMessagesResponse = zod
.describe('An attachment on a message.')
)
.describe('Attachments on this reply.'),
- content: zod.string().describe('Reply content.'),
+ content: zod
+ .string()
+ .nullish()
+ .describe(
+ 'Reply content. `None` on agent-turn placeholder messages.'
+ ),
created_at: zod.iso
.datetime({})
.describe('When the reply was created.'),
@@ -3060,8 +3412,19 @@ export const getMessageWithContextResponse = zod
.array(
zod
.object({
+ agent_session_message_id: zod
+ .string()
+ .nullish()
+ .describe(
+ 'The agent session turn this placeholder renders, as the composite\n`\"{agent_session_id}:{turn}\"`. Set only on agent-turn placeholders.'
+ ),
channel_id: zod.uuid().describe('Channel id.'),
- content: zod.string().describe('Message content.'),
+ content: zod
+ .string()
+ .nullish()
+ .describe(
+ 'Message content. `None` on agent-turn placeholder messages.'
+ ),
created_at: zod.iso
.datetime({})
.describe('When the message was created.'),
@@ -3125,6 +3488,12 @@ export const getThreadRepliesParams = zod.object({
export const getThreadRepliesResponseItem = zod
.object({
+ agent_session_message_id: zod
+ .string()
+ .nullish()
+ .describe(
+ 'The agent session turn this placeholder renders, as the composite\n`\"{agent_session_id}:{turn}\"`. Set only on agent-turn placeholders.'
+ ),
attachments: zod
.array(
zod
@@ -3141,7 +3510,10 @@ export const getThreadRepliesResponseItem = zod
.describe('An attachment on a message.')
)
.describe('Attachments on this reply.'),
- content: zod.string().describe('Reply content.'),
+ content: zod
+ .string()
+ .nullish()
+ .describe('Reply content. `None` on agent-turn placeholder messages.'),
created_at: zod.iso.datetime({}).describe('When the reply was created.'),
edited_at: zod.iso
.datetime({})
@@ -3366,12 +3738,20 @@ export const getChannelsResponse = zod
.describe(
'Whether the requesting user is an active participant of the channel.'
),
+ kind: zod
+ .enum(['normal', 'agent'])
+ .describe('Channel kind in API responses.'),
latest_message: zod
.union([
zod.null(),
zod
.object({
- content: zod.string().describe('Message content.'),
+ content: zod
+ .string()
+ .nullish()
+ .describe(
+ 'Message content. `None` on agent-turn placeholder messages.'
+ ),
created_at: zod.iso
.datetime({})
.describe('Creation timestamp.'),
@@ -3402,7 +3782,12 @@ export const getChannelsResponse = zod
zod.null(),
zod
.object({
- content: zod.string().describe('Message content.'),
+ content: zod
+ .string()
+ .nullish()
+ .describe(
+ 'Message content. `None` on agent-turn placeholder messages.'
+ ),
created_at: zod.iso
.datetime({})
.describe('Creation timestamp.'),
@@ -9194,7 +9579,10 @@ export const getItemsSoupResponse = zod
.object({
content: zod
.string()
- .describe('Message content.'),
+ .nullish()
+ .describe(
+ 'Message content. `None` on agent-turn placeholder messages.'
+ ),
created_at: zod.iso
.datetime({})
.describe('Creation timestamp.'),
@@ -9229,7 +9617,10 @@ export const getItemsSoupResponse = zod
.object({
content: zod
.string()
- .describe('Message content.'),
+ .nullish()
+ .describe(
+ 'Message content. `None` on agent-turn placeholder messages.'
+ ),
created_at: zod.iso
.datetime({})
.describe('Creation timestamp.'),
@@ -9307,7 +9698,12 @@ export const getItemsSoupResponse = zod
)
.describe('Attachments on this message.'),
channel_id: zod.uuid().describe('Channel id.'),
- content: zod.string().describe('Message content.'),
+ content: zod
+ .string()
+ .nullish()
+ .describe(
+ 'Message content. `None` on agent-turn placeholder messages.'
+ ),
created_at: zod.iso
.datetime({})
.describe('Creation timestamp.'),
@@ -9393,7 +9789,12 @@ export const getItemsSoupResponse = zod
.describe('An attachment on a message.')
)
.describe('Attachments on this reply.'),
- content: zod.string().describe('Reply content.'),
+ content: zod
+ .string()
+ .nullish()
+ .describe(
+ 'Reply content. `None` on agent-turn placeholder messages.'
+ ),
created_at: zod.iso
.datetime({})
.describe('Creation timestamp.'),
@@ -12423,7 +12824,10 @@ export const postItemsSoupResponse = zod
.object({
content: zod
.string()
- .describe('Message content.'),
+ .nullish()
+ .describe(
+ 'Message content. `None` on agent-turn placeholder messages.'
+ ),
created_at: zod.iso
.datetime({})
.describe('Creation timestamp.'),
@@ -12458,7 +12862,10 @@ export const postItemsSoupResponse = zod
.object({
content: zod
.string()
- .describe('Message content.'),
+ .nullish()
+ .describe(
+ 'Message content. `None` on agent-turn placeholder messages.'
+ ),
created_at: zod.iso
.datetime({})
.describe('Creation timestamp.'),
@@ -12536,7 +12943,12 @@ export const postItemsSoupResponse = zod
)
.describe('Attachments on this message.'),
channel_id: zod.uuid().describe('Channel id.'),
- content: zod.string().describe('Message content.'),
+ content: zod
+ .string()
+ .nullish()
+ .describe(
+ 'Message content. `None` on agent-turn placeholder messages.'
+ ),
created_at: zod.iso
.datetime({})
.describe('Creation timestamp.'),
@@ -12622,7 +13034,12 @@ export const postItemsSoupResponse = zod
.describe('An attachment on a message.')
)
.describe('Attachments on this reply.'),
- content: zod.string().describe('Reply content.'),
+ content: zod
+ .string()
+ .nullish()
+ .describe(
+ 'Reply content. `None` on agent-turn placeholder messages.'
+ ),
created_at: zod.iso
.datetime({})
.describe('Creation timestamp.'),
@@ -15147,7 +15564,10 @@ export const postItemsSoupAstResponse = zod
.object({
content: zod
.string()
- .describe('Message content.'),
+ .nullish()
+ .describe(
+ 'Message content. `None` on agent-turn placeholder messages.'
+ ),
created_at: zod.iso
.datetime({})
.describe('Creation timestamp.'),
@@ -15182,7 +15602,10 @@ export const postItemsSoupAstResponse = zod
.object({
content: zod
.string()
- .describe('Message content.'),
+ .nullish()
+ .describe(
+ 'Message content. `None` on agent-turn placeholder messages.'
+ ),
created_at: zod.iso
.datetime({})
.describe('Creation timestamp.'),
@@ -15260,7 +15683,12 @@ export const postItemsSoupAstResponse = zod
)
.describe('Attachments on this message.'),
channel_id: zod.uuid().describe('Channel id.'),
- content: zod.string().describe('Message content.'),
+ content: zod
+ .string()
+ .nullish()
+ .describe(
+ 'Message content. `None` on agent-turn placeholder messages.'
+ ),
created_at: zod.iso
.datetime({})
.describe('Creation timestamp.'),
@@ -15346,7 +15774,12 @@ export const postItemsSoupAstResponse = zod
.describe('An attachment on a message.')
)
.describe('Attachments on this reply.'),
- content: zod.string().describe('Reply content.'),
+ content: zod
+ .string()
+ .nullish()
+ .describe(
+ 'Reply content. `None` on agent-turn placeholder messages.'
+ ),
created_at: zod.iso
.datetime({})
.describe('Creation timestamp.'),
@@ -18173,7 +18606,10 @@ export const postItemsSoupAstGroupedResponse = zod
.object({
content: zod
.string()
- .describe('Message content.'),
+ .nullish()
+ .describe(
+ 'Message content. `None` on agent-turn placeholder messages.'
+ ),
created_at: zod.iso
.datetime({})
.describe('Creation timestamp.'),
@@ -18212,7 +18648,10 @@ export const postItemsSoupAstGroupedResponse = zod
.object({
content: zod
.string()
- .describe('Message content.'),
+ .nullish()
+ .describe(
+ 'Message content. `None` on agent-turn placeholder messages.'
+ ),
created_at: zod.iso
.datetime({})
.describe('Creation timestamp.'),
@@ -18296,7 +18735,12 @@ export const postItemsSoupAstGroupedResponse = zod
)
.describe('Attachments on this message.'),
channel_id: zod.uuid().describe('Channel id.'),
- content: zod.string().describe('Message content.'),
+ content: zod
+ .string()
+ .nullish()
+ .describe(
+ 'Message content. `None` on agent-turn placeholder messages.'
+ ),
created_at: zod.iso
.datetime({})
.describe('Creation timestamp.'),
@@ -18390,7 +18834,10 @@ export const postItemsSoupAstGroupedResponse = zod
.describe('Attachments on this reply.'),
content: zod
.string()
- .describe('Reply content.'),
+ .nullish()
+ .describe(
+ 'Reply content. `None` on agent-turn placeholder messages.'
+ ),
created_at: zod.iso
.datetime({})
.describe('Creation timestamp.'),
@@ -20915,7 +21362,10 @@ export const postItemsSoupAstGroupedResponse = zod
.object({
content: zod
.string()
- .describe('Message content.'),
+ .nullish()
+ .describe(
+ 'Message content. `None` on agent-turn placeholder messages.'
+ ),
created_at: zod.iso
.datetime({})
.describe('Creation timestamp.'),
@@ -20954,7 +21404,10 @@ export const postItemsSoupAstGroupedResponse = zod
.object({
content: zod
.string()
- .describe('Message content.'),
+ .nullish()
+ .describe(
+ 'Message content. `None` on agent-turn placeholder messages.'
+ ),
created_at: zod.iso
.datetime({})
.describe('Creation timestamp.'),
@@ -21038,7 +21491,12 @@ export const postItemsSoupAstGroupedResponse = zod
)
.describe('Attachments on this message.'),
channel_id: zod.uuid().describe('Channel id.'),
- content: zod.string().describe('Message content.'),
+ content: zod
+ .string()
+ .nullish()
+ .describe(
+ 'Message content. `None` on agent-turn placeholder messages.'
+ ),
created_at: zod.iso
.datetime({})
.describe('Creation timestamp.'),
@@ -21132,7 +21590,10 @@ export const postItemsSoupAstGroupedResponse = zod
.describe('Attachments on this reply.'),
content: zod
.string()
- .describe('Reply content.'),
+ .nullish()
+ .describe(
+ 'Reply content. `None` on agent-turn placeholder messages.'
+ ),
created_at: zod.iso
.datetime({})
.describe('Creation timestamp.'),
diff --git a/apps/web/src/lib/service-clients/service-storage/openapi.json b/apps/web/src/lib/service-clients/service-storage/openapi.json
index ecc10fcfded..97394740a07 100644
--- a/apps/web/src/lib/service-clients/service-storage/openapi.json
+++ b/apps/web/src/lib/service-clients/service-storage/openapi.json
@@ -82,6 +82,78 @@
"deprecated": true
}
},
+ "/agent-sessions/channel/{channel_id}/messages": {
+ "get": {
+ "tags": ["agent-sessions"],
+ "summary": "The folded messages of the agent session behind a channel.",
+ "description": "Placeholder comms messages in an agent channel store no body, only an\n`agent_session_message_id`; each message here carries the same composite id,\nso a reader joins the two to render the channel. `404` when no agent\nsession owns the channel.",
+ "operationId": "get_agent_channel_messages",
+ "parameters": [
+ {
+ "name": "channel_id",
+ "in": "path",
+ "description": "ID of the session's dedicated channel",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "format": "uuid"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AgentChannelMessagesResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "",
+ "content": {
+ "text/plain": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "",
+ "content": {
+ "text/plain": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "",
+ "content": {
+ "text/plain": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "",
+ "content": {
+ "text/plain": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/agent-sessions/{session_id}": {
"get": {
"tags": ["agent-sessions"],
@@ -10816,6 +10888,25 @@
}
}
},
+ "AgentChannelMessagesResponse": {
+ "type": "object",
+ "description": "Response body for a channel's folded agent-session messages.",
+ "required": ["agentSessionId", "messages"],
+ "properties": {
+ "agentSessionId": {
+ "type": "string",
+ "format": "uuid",
+ "description": "The session whose log derived the messages."
+ },
+ "messages": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/FoldedMessageDto"
+ },
+ "description": "The session's folded messages, oldest first."
+ }
+ }
+ },
"AgentSessionResponse": {
"type": "object",
"description": "Response body describing an agent session.",
@@ -11151,7 +11242,6 @@
"channel_id",
"message_id",
"sender_id",
- "message_content",
"message_created_at",
"attachment_created_at"
],
@@ -11171,8 +11261,8 @@
"description": "Optional channel name (DMs do not have a name)."
},
"message_content": {
- "type": "string",
- "description": "Full message content (might be used for preview/snippet)."
+ "type": ["string", "null"],
+ "description": "Full message content (might be used for preview/snippet). `None` on\nagent-turn placeholder messages."
},
"message_created_at": {
"type": "string",
@@ -11359,19 +11449,22 @@
"channel_id",
"sender_id",
"sender",
- "content",
"created_at",
"updated_at"
],
"properties": {
+ "agent_session_message_id": {
+ "type": ["string", "null"],
+ "description": "The agent session turn this placeholder renders, as the composite\n`\"{agent_session_id}:{turn}\"`. Set only on agent-turn placeholders."
+ },
"channel_id": {
"type": "string",
"format": "uuid",
"description": "Channel id."
},
"content": {
- "type": "string",
- "description": "Message content."
+ "type": ["string", "null"],
+ "description": "Message content. `None` on agent-turn placeholder messages."
},
"created_at": {
"type": "string",
@@ -11453,21 +11546,25 @@
}
}
},
+ "ApiChannelKind": {
+ "type": "string",
+ "description": "Channel kind in API responses.",
+ "enum": ["normal", "agent"]
+ },
"ApiChannelListMessage": {
"type": "object",
"description": "Channel message in API responses.",
"required": [
"message_id",
"sender_id",
- "content",
"created_at",
"updated_at",
"mentions"
],
"properties": {
"content": {
- "type": "string",
- "description": "Message content."
+ "type": ["string", "null"],
+ "description": "Message content. `None` on agent-turn placeholder messages."
},
"created_at": {
"type": "string",
@@ -11568,7 +11665,6 @@
"channel_id",
"sender_id",
"sender",
- "content",
"created_at",
"updated_at",
"thread",
@@ -11576,6 +11672,10 @@
"attachments"
],
"properties": {
+ "agent_session_message_id": {
+ "type": ["string", "null"],
+ "description": "The agent session turn this placeholder renders, as the composite\n`\"{agent_session_id}:{turn}\"`. Set only on agent-turn placeholders;\njoin it against the folded messages of the channel's agent session."
+ },
"attachments": {
"type": "array",
"items": {
@@ -11589,8 +11689,8 @@
"description": "Channel id."
},
"content": {
- "type": "string",
- "description": "Message content."
+ "type": ["string", "null"],
+ "description": "Message content. `None` on agent-turn placeholder messages, whose body\nis folded from the agent session log and joined in by the client."
},
"created_at": {
"type": "string",
@@ -11696,6 +11796,7 @@
"required": [
"id",
"channel_type",
+ "kind",
"auto_join_team",
"created_at",
"updated_at",
@@ -11736,6 +11837,10 @@
"type": "boolean",
"description": "Whether the requesting user is an active participant of the channel."
},
+ "kind": {
+ "$ref": "#/components/schemas/ApiChannelKind",
+ "description": "Channel kind: `normal`, or `agent` for an agent session's dedicated\nchannel whose agent messages are folded placeholders."
+ },
"latest_message": {
"oneOf": [
{
@@ -12114,13 +12219,16 @@
"id",
"sender_id",
"sender",
- "content",
"created_at",
"updated_at",
"reactions",
"attachments"
],
"properties": {
+ "agent_session_message_id": {
+ "type": ["string", "null"],
+ "description": "The agent session turn this placeholder renders, as the composite\n`\"{agent_session_id}:{turn}\"`. Set only on agent-turn placeholders."
+ },
"attachments": {
"type": "array",
"items": {
@@ -12129,8 +12237,8 @@
"description": "Attachments on this reply."
},
"content": {
- "type": "string",
- "description": "Reply content."
+ "type": ["string", "null"],
+ "description": "Reply content. `None` on agent-turn placeholder messages."
},
"created_at": {
"type": "string",
@@ -13430,15 +13538,14 @@
"required": [
"message_id",
"sender_id",
- "content",
"created_at",
"updated_at",
"mentions"
],
"properties": {
"content": {
- "type": "string",
- "description": "Message content."
+ "type": ["string", "null"],
+ "description": "Message content. `None` on agent-turn placeholder messages."
},
"created_at": {
"type": "string",
@@ -17464,6 +17571,25 @@
}
}
},
+ "FileDiffDto": {
+ "type": "object",
+ "description": "A file modification a tool reported, mirroring [`FileDiff`].",
+ "required": ["path", "newText"],
+ "properties": {
+ "newText": {
+ "type": "string",
+ "description": "New contents."
+ },
+ "oldText": {
+ "type": ["string", "null"],
+ "description": "Prior contents, absent when the file is new."
+ },
+ "path": {
+ "type": "string",
+ "description": "The file that changed."
+ }
+ }
+ },
"FileSystemNodeWithIds": {
"oneOf": [
{
@@ -17955,6 +18081,171 @@
],
"description": "Represents a file type update: either set to a specific type or clear to null."
},
+ "FoldedAuthorDto": {
+ "oneOf": [
+ {
+ "type": "object",
+ "description": "A person, via `session/prompt`.",
+ "required": ["kind"],
+ "properties": {
+ "kind": {
+ "type": "string",
+ "enum": ["user"]
+ },
+ "userId": {
+ "type": ["string", "null"],
+ "description": "The user's macro id, absent when the prompt was unattributed."
+ }
+ }
+ },
+ {
+ "type": "object",
+ "description": "The agent.",
+ "required": ["kind"],
+ "properties": {
+ "kind": {
+ "type": "string",
+ "enum": ["agent"]
+ }
+ }
+ }
+ ],
+ "description": "Who produced a folded message, mirroring [`Author`].\n\nMulti-word fields in these enums carry explicit `#[serde(rename)]`s\ninstead of `rename_all_fields = \"camelCase\"`: utoipa does not read\n`rename_all_fields`, so the explicit form keeps the generated schema and\nthe serialized wire format in agreement."
+ },
+ "FoldedMessageDto": {
+ "type": "object",
+ "description": "One renderable message folded from a session's protocol log, mirroring\n[`FoldedMessage`].",
+ "required": ["agentSessionMessageId", "turn", "author", "parts"],
+ "properties": {
+ "agentSessionMessageId": {
+ "type": "string",
+ "description": "The composite id the placeholder comms message for this folded message\ncarries in its `agent_session_message_id`:\n`\"{agent_session_id}:{turn}:{author}\"`. Readers join folded messages\nonto placeholder rows by this, one to one."
+ },
+ "author": {
+ "$ref": "#/components/schemas/FoldedAuthorDto",
+ "description": "Who produced the message."
+ },
+ "parts": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/FoldedMessagePartDto"
+ },
+ "description": "Ordered renderable content. Never empty."
+ },
+ "stop": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/StopReasonDto",
+ "description": "How the turn ended, on the agent message that closed it. Absent while\nthe turn is in flight or when the session died without a response."
+ }
+ ]
+ },
+ "turn": {
+ "type": "integer",
+ "format": "int32",
+ "description": "The turn within the session, assigned in log order from zero.",
+ "minimum": 0
+ }
+ }
+ },
+ "FoldedMessagePartDto": {
+ "oneOf": [
+ {
+ "type": "object",
+ "description": "Prose from the user or the agent.",
+ "required": ["text", "kind"],
+ "properties": {
+ "kind": {
+ "type": "string",
+ "enum": ["text"]
+ },
+ "text": {
+ "type": "string",
+ "description": "The prose."
+ }
+ }
+ },
+ {
+ "type": "object",
+ "description": "The agent's reasoning, which a reader may want to hide by default.",
+ "required": ["text", "kind"],
+ "properties": {
+ "kind": {
+ "type": "string",
+ "enum": ["thought"]
+ },
+ "text": {
+ "type": "string",
+ "description": "The reasoning."
+ }
+ }
+ },
+ {
+ "type": "object",
+ "description": "A tool the agent invoked.",
+ "required": ["id", "label", "status", "detail", "kind"],
+ "properties": {
+ "detail": {
+ "$ref": "#/components/schemas/ToolDetailDto",
+ "description": "What the tool did, as far as the log reveals."
+ },
+ "id": {
+ "type": "string",
+ "description": "The ACP `toolCallId`."
+ },
+ "kind": {
+ "type": "string",
+ "enum": ["tool_use"]
+ },
+ "label": {
+ "type": "string",
+ "description": "What to show as the tool's name."
+ },
+ "status": {
+ "$ref": "#/components/schemas/ToolStatusDto",
+ "description": "Where the call got to."
+ }
+ }
+ },
+ {
+ "type": "object",
+ "description": "The agent asking to proceed with a tool call.",
+ "required": ["toolCall", "options", "kind"],
+ "properties": {
+ "kind": {
+ "type": "string",
+ "enum": ["permission"]
+ },
+ "options": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/PermissionOptionDto"
+ },
+ "description": "The choices offered, in the order ACP listed them."
+ },
+ "outcome": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/PermissionOutcomeDto",
+ "description": "What the user chose. Absent while the request is outstanding, or\nwhen the session ended before anyone answered."
+ }
+ ]
+ },
+ "toolCall": {
+ "type": "string",
+ "description": "The `toolCallId` permission was requested for."
+ }
+ }
+ }
+ ],
+ "description": "A unit of renderable content, mirroring [`MessagePart`]."
+ },
"FolderItem": {
"type": "object",
"required": ["name", "fullName", "relativePath", "sha"],
@@ -19889,6 +20180,56 @@
}
}
},
+ "PermissionOptionDto": {
+ "type": "object",
+ "description": "One choice offered for a permission request, mirroring\n[`PermissionOption`].",
+ "required": ["id", "name", "kind"],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The id to report back when this option is chosen."
+ },
+ "kind": {
+ "type": "string",
+ "description": "ACP's option kind, as its wire string - `allow_once`, `reject_once`,\n`allow_always`, `reject_always`."
+ },
+ "name": {
+ "type": "string",
+ "description": "Label to show."
+ }
+ }
+ },
+ "PermissionOutcomeDto": {
+ "oneOf": [
+ {
+ "type": "object",
+ "description": "An option was chosen.",
+ "required": ["optionId", "kind"],
+ "properties": {
+ "kind": {
+ "type": "string",
+ "enum": ["selected"]
+ },
+ "optionId": {
+ "type": "string",
+ "description": "The chosen option's id."
+ }
+ }
+ },
+ {
+ "type": "object",
+ "description": "The request was cancelled without a choice.",
+ "required": ["kind"],
+ "properties": {
+ "kind": {
+ "type": "string",
+ "enum": ["cancelled"]
+ }
+ }
+ }
+ ],
+ "description": "How a permission request resolved, mirroring [`PermissionOutcome`]."
+ },
"PinRequest": {
"type": "object",
"required": ["pinType"],
@@ -21511,7 +21852,6 @@
"channel_id",
"sender_id",
"sender",
- "content",
"created_at",
"updated_at",
"thread",
@@ -21532,8 +21872,8 @@
"description": "Channel id."
},
"content": {
- "type": "string",
- "description": "Message content."
+ "type": ["string", "null"],
+ "description": "Message content. `None` on agent-turn placeholder messages."
},
"created_at": {
"type": "string",
@@ -22575,7 +22915,6 @@
"id",
"sender_id",
"sender",
- "content",
"created_at",
"updated_at",
"reactions",
@@ -22590,8 +22929,8 @@
"description": "Attachments on this reply."
},
"content": {
- "type": "string",
- "description": "Reply content."
+ "type": ["string", "null"],
+ "description": "Reply content. `None` on agent-turn placeholder messages."
},
"created_at": {
"type": "string",
@@ -22641,6 +22980,81 @@
}
}
},
+ "StopReasonDto": {
+ "oneOf": [
+ {
+ "type": "object",
+ "description": "The agent finished its turn.",
+ "required": ["kind"],
+ "properties": {
+ "kind": {
+ "type": "string",
+ "enum": ["end_turn"]
+ }
+ }
+ },
+ {
+ "type": "object",
+ "description": "The model hit its token limit.",
+ "required": ["kind"],
+ "properties": {
+ "kind": {
+ "type": "string",
+ "enum": ["max_tokens"]
+ }
+ }
+ },
+ {
+ "type": "object",
+ "description": "The agent hit its turn-request limit.",
+ "required": ["kind"],
+ "properties": {
+ "kind": {
+ "type": "string",
+ "enum": ["max_turn_requests"]
+ }
+ }
+ },
+ {
+ "type": "object",
+ "description": "The agent declined.",
+ "required": ["kind"],
+ "properties": {
+ "kind": {
+ "type": "string",
+ "enum": ["refusal"]
+ }
+ }
+ },
+ {
+ "type": "object",
+ "description": "The turn was cancelled.",
+ "required": ["kind"],
+ "properties": {
+ "kind": {
+ "type": "string",
+ "enum": ["cancelled"]
+ }
+ }
+ },
+ {
+ "type": "object",
+ "description": "A stop reason the fold does not model.",
+ "required": ["reason", "kind"],
+ "properties": {
+ "kind": {
+ "type": "string",
+ "enum": ["other"]
+ },
+ "reason": {
+ "type": "string",
+ "description": "The wire string."
+ }
+ }
+ }
+ ],
+ "description": "Why a turn stopped, mirroring [`StopReason`]."
+ },
"String": {
"type": "string"
},
@@ -22735,6 +23149,95 @@
}
}
},
+ "ToolDetailDto": {
+ "oneOf": [
+ {
+ "type": "object",
+ "description": "A shell command.",
+ "required": ["kind"],
+ "properties": {
+ "command": {
+ "type": ["string", "null"],
+ "description": "The command line, when the harness reported one."
+ },
+ "exitCode": {
+ "type": ["integer", "null"],
+ "format": "int32",
+ "description": "Process exit code, when the harness reported one."
+ },
+ "kind": {
+ "type": "string",
+ "enum": ["terminal"]
+ },
+ "output": {
+ "type": ["string", "null"],
+ "description": "Captured output, ANSI escape sequences left in place."
+ }
+ }
+ },
+ {
+ "type": "object",
+ "description": "One or more file modifications.",
+ "required": ["diffs", "kind"],
+ "properties": {
+ "diffs": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/FileDiffDto"
+ },
+ "description": "The diffs ACP reported for this call."
+ },
+ "kind": {
+ "type": "string",
+ "enum": ["edit"]
+ }
+ }
+ },
+ {
+ "type": "object",
+ "description": "A file read.",
+ "required": ["paths", "kind"],
+ "properties": {
+ "kind": {
+ "type": "string",
+ "enum": ["read"]
+ },
+ "paths": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Paths this call touched."
+ }
+ }
+ },
+ {
+ "type": "object",
+ "description": "Anything else, including tools the fold has no special rendering for.",
+ "required": ["acpKind", "kind"],
+ "properties": {
+ "acpKind": {
+ "type": "string",
+ "description": "ACP's tool kind, as its wire string."
+ },
+ "input": {
+ "type": ["object", "null"],
+ "description": "The tool's input, when reported."
+ },
+ "kind": {
+ "type": "string",
+ "enum": ["other"]
+ }
+ }
+ }
+ ],
+ "description": "What a tool call actually did, mirroring [`ToolDetail`]."
+ },
+ "ToolStatusDto": {
+ "type": "string",
+ "description": "How far a tool call progressed, mirroring [`ToolStatus`].",
+ "enum": ["pending", "running", "completed", "failed"]
+ },
"TranscriptSegmentRequest": {
"type": "object",
"description": "A transcript segment from LiveKit Inference STT.",
diff --git a/crates/agent_fold/Cargo.toml b/crates/agent_fold/Cargo.toml
index e2fcfcaa7f0..331ed825159 100644
--- a/crates/agent_fold/Cargo.toml
+++ b/crates/agent_fold/Cargo.toml
@@ -4,10 +4,14 @@ name = "agent_fold"
publish = false
version = "0.1.0"
+[features]
+# In-memory port implementations and recorded fixtures, for crates that test
+# against this one.
+test-utils = []
+
[dependencies]
agent-client-protocol = { workspace = true }
agent_runtime_protocol = { path = "../agent_runtime_protocol" }
-agent_session = { path = "../agent_session" }
clap = { workspace = true }
macro_user_id = { path = "../macro_user_id" }
macro_uuid = { path = "../macro_uuid" }
@@ -19,6 +23,3 @@ thiserror = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
workspace-hack = { version = "0.1", path = "../workspace-hack" }
-
-[dev-dependencies]
-agent_session = { path = "../agent_session", features = ["test-utils"] }
diff --git a/crates/agent_fold/examples/fold_recording.rs b/crates/agent_fold/examples/fold_recording.rs
index b0b9745ccb1..685851a6c6e 100644
--- a/crates/agent_fold/examples/fold_recording.rs
+++ b/crates/agent_fold/examples/fold_recording.rs
@@ -10,11 +10,11 @@
//! cargo run -p agent_fold --example fold_recording -- ~/.agent_runtime_sessions/.jsonl
//! ```
+use agent_fold::domain::log::{AgentSessionId, AgentSessionLog, Message};
use agent_fold::domain::model::{Author, MessagePart, ToolDetail};
use agent_fold::domain::ports::{FoldedMessageRepo, LogRepo};
use agent_fold::domain::service::FoldedMessageService;
-use agent_session::domain::error::AgentSessionError;
-use agent_session::domain::model::{AgentSessionId, AgentSessionLog, Message};
+use macro_uuid::Uuid;
use std::collections::VecDeque;
/// A [`LogRepo`] over one recording read straight off disk.
@@ -27,7 +27,7 @@ impl LogRepo for RecordedLog {
async fn list_by_session(
&self,
session: AgentSessionId,
- ) -> Result, AgentSessionError> {
+ ) -> Result, rootcause::Report> {
Ok(if session == self.session {
self.entries.clone()
} else {
@@ -43,7 +43,7 @@ async fn main() {
.expect("usage: fold_recording ");
let jsonl = std::fs::read_to_string(&path).expect("recording is readable");
- let session = AgentSessionId::TEST_A;
+ let session = AgentSessionId::new_from_uuid(Uuid::from_u128(1));
let repo = RecordedLog {
session,
entries: jsonl
diff --git a/crates/agent_fold/src/bin/fold_jsonl.rs b/crates/agent_fold/src/bin/fold_jsonl.rs
index 0d1214336c4..624416265b9 100644
--- a/crates/agent_fold/src/bin/fold_jsonl.rs
+++ b/crates/agent_fold/src/bin/fold_jsonl.rs
@@ -10,14 +10,13 @@
//! ```
use agent_fold::domain::fold::fold;
+use agent_fold::domain::log::{AgentSessionId, AgentSessionLog, Message};
use agent_fold::domain::model::{
Author, FoldedMessage, MessagePart, Permission, PermissionOutcome, StopReason, ToolDetail,
ToolStatus, ToolUse,
};
use agent_fold::domain::ports::LogRepo;
use agent_runtime_protocol::domain::schema::v0::ToServerMessage;
-use agent_session::domain::error::AgentSessionError;
-use agent_session::domain::model::{AgentSessionId, AgentSessionLog, Message};
use clap::Parser;
use serde::Deserialize;
use std::collections::VecDeque;
@@ -104,7 +103,7 @@ impl LogRepo for JsonlRecording {
async fn list_by_session(
&self,
session: AgentSessionId,
- ) -> Result, AgentSessionError> {
+ ) -> Result, rootcause::Report> {
Ok(if session == self.session {
self.entries.clone()
} else {
diff --git a/crates/agent_fold/src/domain/fold.rs b/crates/agent_fold/src/domain/fold.rs
index 847bf680745..f1432bcdf04 100644
--- a/crates/agent_fold/src/domain/fold.rs
+++ b/crates/agent_fold/src/domain/fold.rs
@@ -30,6 +30,7 @@
//! top-to-bottom, and every frame it ignores is an explicit arm.
use crate::domain::error::FoldError;
+use crate::domain::log::{AgentSessionId, AgentSessionLog, Message};
use crate::domain::meta::{claude_code, command_from_raw_input};
use crate::domain::model::{
AnsiText, Author, FileDiff, FoldedMessage, MessagePart, Permission, PermissionOption,
@@ -42,7 +43,6 @@ use agent_client_protocol::schema::v1::{
};
use agent_client_protocol::{RawJsonRpcMessage, RawJsonRpcParams};
use agent_runtime_protocol::domain::schema::v0::{ToRuntimeMessage, ToServerMessage};
-use agent_session::domain::model::{AgentSessionId, AgentSessionLog, Message};
use macro_user_id::user_id::MacroUserIdStr;
use non_empty::NonEmpty;
use serde::Deserialize;
@@ -130,10 +130,7 @@ impl FoldSession for T {
&self,
session: AgentSessionId,
) -> Result, rootcause::Report> {
- let log = self
- .list_by_session(session)
- .await
- .map_err(|error| rootcause::report!(error))?;
+ let log = self.list_by_session(session).await?;
Ok(fold(log))
}
}
diff --git a/crates/agent_fold/src/domain/log.rs b/crates/agent_fold/src/domain/log.rs
new file mode 100644
index 00000000000..bcbee2d1573
--- /dev/null
+++ b/crates/agent_fold/src/domain/log.rs
@@ -0,0 +1,80 @@
+//! The raw protocol log vocabulary the fold consumes.
+//!
+//! These types describe one entry of an agent session's protocol log: which
+//! session it belongs to, which direction the frame travelled, and who (if
+//! anyone) originated it. They live in this crate - the bottom of the agent
+//! session stack - so that storage and orchestration (`agent_session`) can
+//! depend on the fold rather than the other way around; `agent_session`
+//! re-exports them from its own model module, which is where most callers
+//! meet them.
+
+use agent_runtime_protocol::domain::schema::v0::{ToRuntimeMessage, ToServerMessage};
+use macro_user_id::user_id::MacroUserIdStr;
+use macro_uuid::Uuid;
+use serde::{Deserialize, Serialize};
+
+/// The identity of an agent session.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(transparent)]
+pub struct AgentSessionId(Uuid);
+
+impl AgentSessionId {
+ /// A fixed id for tests.
+ #[cfg(any(test, feature = "test-utils"))]
+ pub const TEST_A: Self = Self(Uuid::from_u128(0xA));
+
+ /// A second fixed id for tests that need two distinct sessions.
+ #[cfg(any(test, feature = "test-utils"))]
+ pub const TEST_B: Self = Self(Uuid::from_u128(0xB));
+
+ /// Mint a fresh session id, backed by a UUIDv7.
+ #[expect(clippy::new_without_default, reason = "each call mints a distinct id")]
+ #[must_use]
+ pub fn new() -> Self {
+ Self(macro_uuid::generate_uuid_v7())
+ }
+
+ /// Wrap an existing UUID as an agent session id.
+ #[must_use]
+ pub fn new_from_uuid(id: Uuid) -> Self {
+ Self(id)
+ }
+
+ /// The underlying UUID.
+ #[must_use]
+ pub fn as_uuid(&self) -> Uuid {
+ self.0
+ }
+}
+
+impl std::fmt::Display for AgentSessionId {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ self.0.fmt(formatter)
+ }
+}
+
+/// One protocol frame, tagged with the direction it travelled.
+///
+/// Serializes as `{"direction": "to_server" | "to_runtime", "content": }`,
+/// the same vocabulary the Postgres log storage uses for its `direction` and
+/// `content` columns, so recorded fixtures and stored rows share one wire
+/// format.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "direction", content = "content", rename_all = "snake_case")]
+pub enum Message {
+ /// Runtime → server traffic.
+ ToServer(ToServerMessage),
+ /// Server → runtime traffic.
+ ToRuntime(ToRuntimeMessage),
+}
+
+/// One entry of a session's protocol log.
+#[derive(Debug, Clone)]
+pub struct AgentSessionLog {
+ /// The session the entry belongs to.
+ pub agent_session_id: AgentSessionId,
+ /// if this is ACP sent by a user this will be Some
+ pub user_id: Option>,
+ /// The logged frame.
+ pub content: Message,
+}
diff --git a/crates/agent_fold/src/domain/mod.rs b/crates/agent_fold/src/domain/mod.rs
index 18fc2561a24..cb098400229 100644
--- a/crates/agent_fold/src/domain/mod.rs
+++ b/crates/agent_fold/src/domain/mod.rs
@@ -5,6 +5,8 @@
mod error;
/// Collapsing a protocol log into messages.
pub mod fold;
+/// The raw log vocabulary the fold consumes.
+pub mod log;
/// Harness-specific `_meta` extraction.
pub mod meta;
/// The renderable message vocabulary.
diff --git a/crates/agent_fold/src/domain/model.rs b/crates/agent_fold/src/domain/model.rs
index 803de2e6b41..a63d83d5f32 100644
--- a/crates/agent_fold/src/domain/model.rs
+++ b/crates/agent_fold/src/domain/model.rs
@@ -21,6 +21,9 @@ use std::path::PathBuf;
/// Assigned by the fold in log order starting at zero, so the same log always
/// yields the same ids. This is not the ACP request id - that correlation is
/// internal to the fold.
+///
+/// A turn is not the unit a channel renders - see [`MessageId`], which is
+/// what a comms placeholder stores.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct TurnId(pub u32);
@@ -36,7 +39,13 @@ pub struct ToolUseId(pub String);
/// a message - and because [`TurnId`]s are assigned in log order, the same
/// log always derives the same keys. Queries that pretend the messages are
/// stored address them by this.
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+///
+/// The one exception to "nothing here is persisted": a comms placeholder
+/// message stores the message it renders, as `"{turn}:{author}"` under a
+/// session prefix, relying on exactly this stability. One placeholder per
+/// message, not per turn - a turn's prompt and its reply are separate rows
+/// so each can carry its own sender.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct MessageId {
/// The turn the message belongs to.
pub turn: TurnId,
@@ -44,11 +53,29 @@ pub struct MessageId {
pub author: AuthorKind,
}
+impl std::fmt::Display for MessageId {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(formatter, "{}:{}", self.turn.0, self.author.as_str())
+ }
+}
+
+impl std::str::FromStr for MessageId {
+ type Err = ();
+
+ fn from_str(value: &str) -> Result {
+ let (turn, author) = value.split_once(':').ok_or(())?;
+ Ok(Self {
+ turn: TurnId(turn.parse().map_err(|_| ())?),
+ author: author.parse()?,
+ })
+ }
+}
+
/// Which side of the conversation produced a message.
///
/// The identity-free discriminant of [`Author`], usable in a key where
/// [`Author`] itself carries a payload.
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum AuthorKind {
/// A person.
User,
@@ -56,6 +83,29 @@ pub enum AuthorKind {
Agent,
}
+impl AuthorKind {
+ /// The wire name, as it appears in a [`MessageId`]'s string form.
+ #[must_use]
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::User => "user",
+ Self::Agent => "agent",
+ }
+ }
+}
+
+impl std::str::FromStr for AuthorKind {
+ type Err = ();
+
+ fn from_str(value: &str) -> Result {
+ match value {
+ "user" => Ok(Self::User),
+ "agent" => Ok(Self::Agent),
+ _ => Err(()),
+ }
+ }
+}
+
/// One renderable message folded out of a session's protocol log.
///
/// A turn produces at most two of these: the user's prompt and the agent's
diff --git a/crates/agent_fold/src/domain/ports.rs b/crates/agent_fold/src/domain/ports.rs
index 6eb96904e89..45771d1ea9b 100644
--- a/crates/agent_fold/src/domain/ports.rs
+++ b/crates/agent_fold/src/domain/ports.rs
@@ -9,11 +9,10 @@
//! materialized somewhere, only the implementation moves.
//!
//! [`LogRepo`] is the driven port: the one capability folding needs from the
-//! outside, reading a session's raw log. It deliberately does not reuse
-//! [`agent_session::domain::ports::AgentSessionLogRepo`] - this crate names
-//! the contract it depends on, rather than reaching into another domain's
-//! ports module, so whatever stores the log (today, `agent_session`'s
-//! Postgres adapter) is the one that implements it.
+//! outside, reading a session's raw log. This crate sits at the bottom of
+//! the agent session stack, so it names the contract in its own vocabulary
+//! ([`crate::domain::log`]) and whoever stores the log (today,
+//! `agent_session`'s Postgres adapter) implements it.
//!
//! [`FoldSession`] sits between the two: "fold this session's log" as its own
//! capability, blanket-implemented for anything that is a [`LogRepo`] so
@@ -24,22 +23,21 @@
//! deliberate choice rather than falling out of whatever implements
//! [`FoldSession`].
+use crate::domain::log::{AgentSessionId, AgentSessionLog};
use crate::domain::model::{FoldedMessage, MessageId};
-use agent_session::domain::error::AgentSessionError;
-use agent_session::domain::model::{AgentSessionId, AgentSessionLog};
use std::collections::VecDeque;
/// Read a session's raw protocol log.
///
-/// The one capability folding needs from storage, named by this crate rather
-/// than borrowed from `agent_session`'s own ports - see the module docs.
+/// The one capability folding needs from storage, named by this crate in its
+/// own vocabulary - see the module docs.
pub trait LogRepo {
/// A session's log rows, oldest first. A session with no log returns an
/// empty queue.
fn list_by_session(
&self,
session: AgentSessionId,
- ) -> impl Future, AgentSessionError>> + Send;
+ ) -> impl Future, rootcause::Report>> + Send;
}
/// Fold a session's log into renderable messages.
@@ -69,4 +67,16 @@ pub trait FoldedMessageRepo {
session: AgentSessionId,
id: MessageId,
) -> impl Future, rootcause::Report>> + Send;
+
+ /// The keys of the messages the session's log derives, oldest first. A
+ /// session with no log derives none.
+ ///
+ /// This is what `agent_session`'s service polls on every append to decide
+ /// which messages still need a comms placeholder. It is keyed per message
+ /// rather than per turn because a turn's prompt and its reply are
+ /// rendered as separate channel messages with different senders.
+ fn message_ids(
+ &self,
+ session: AgentSessionId,
+ ) -> impl Future, rootcause::Report>> + Send;
}
diff --git a/crates/agent_fold/src/domain/service.rs b/crates/agent_fold/src/domain/service.rs
index 189db419532..80b0e8ae200 100644
--- a/crates/agent_fold/src/domain/service.rs
+++ b/crates/agent_fold/src/domain/service.rs
@@ -7,9 +7,9 @@
//! [`LogRepo`](crate::domain::ports::LogRepo) - so the only thing this type
//! adds is the `get_message` lookup, which is just a filter over `messages`.
+use crate::domain::log::AgentSessionId;
use crate::domain::model::{FoldedMessage, MessageId};
use crate::domain::ports::{FoldSession, FoldedMessageRepo};
-use agent_session::domain::model::AgentSessionId;
/// Serves [`FoldedMessageRepo`] queries by delegating to a [`FoldSession`].
#[derive(Debug, Clone)]
@@ -45,4 +45,15 @@ where
let messages = self.sessions.fold_session(session).await?;
Ok(messages.into_iter().find(|message| message.id() == id))
}
+
+ #[tracing::instrument(err, skip(self))]
+ async fn message_ids(
+ &self,
+ session: AgentSessionId,
+ ) -> Result, rootcause::Report> {
+ let messages = self.sessions.fold_session(session).await?;
+ // Already oldest-first and already unique - the fold emits at most one
+ // message per (turn, author side), which is exactly the key.
+ Ok(messages.iter().map(FoldedMessage::id).collect())
+ }
}
diff --git a/crates/agent_fold/src/domain/test/fold.rs b/crates/agent_fold/src/domain/test/fold.rs
index 63b32e69308..63b3b8cbd3f 100644
--- a/crates/agent_fold/src/domain/test/fold.rs
+++ b/crates/agent_fold/src/domain/test/fold.rs
@@ -1,10 +1,10 @@
use super::util::{TURN, parse_log};
use crate::domain::fold::fold;
+use crate::domain::log::AgentSessionLog;
use crate::domain::model::{
Author, FoldedMessage, MessagePart, PermissionOutcome, StopReason, ToolDetail, ToolStatus,
TurnId,
};
-use agent_session::domain::model::AgentSessionLog;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tracing::Level;
diff --git a/crates/agent_fold/src/domain/test/service.rs b/crates/agent_fold/src/domain/test/service.rs
index 91c7fe6f3e4..a1ddc0d6b0b 100644
--- a/crates/agent_fold/src/domain/test/service.rs
+++ b/crates/agent_fold/src/domain/test/service.rs
@@ -1,19 +1,17 @@
-use super::util::{TURN, parse_log_as, test_session};
+use super::util::{InMemoryLog, TURN, parse_log_as, test_session};
use crate::domain::fold;
+use crate::domain::log::AgentSessionId;
use crate::domain::model::{AuthorKind, MessageId, TurnId};
-use crate::domain::ports::FoldedMessageRepo;
+use crate::domain::ports::{FoldedMessageRepo, LogRepo};
use crate::domain::service::FoldedMessageService;
-use agent_session::domain::model::AgentSessionId;
-use agent_session::domain::ports::AgentSessionLogRepo;
-use agent_session::testing::InMemoryAgentSessionRepo;
fn other_session() -> AgentSessionId {
AgentSessionId::TEST_B
}
-/// A repo holding two sessions: the full fixture, and a second session cut
+/// A log holding two sessions: the full fixture, and a second session cut
/// off mid-turn.
-fn two_session_store() -> InMemoryAgentSessionRepo {
+fn two_session_store() -> InMemoryLog {
let mut second = parse_log_as(other_session(), TURN);
second.truncate(9);
@@ -93,7 +91,8 @@ async fn service_matches_the_bare_fold() {
.await
.expect("in-memory store cannot fail");
let via_fold = fold::fold(
- AgentSessionLogRepo::list_by_session(&store, test_session())
+ store
+ .list_by_session(test_session())
.await
.expect("in-memory store cannot fail"),
);
@@ -109,3 +108,38 @@ async fn service_matches_the_bare_fold() {
assert_eq!(fetched.as_ref(), Some(message));
}
}
+
+/// `message_ids` lists one key per derived message - a turn contributes both
+/// its user and its agent side, keyed apart by author, so each gets its own
+/// placeholder. An unlogged session derives none.
+#[tokio::test]
+async fn message_ids_lists_each_message_once() {
+ let service = FoldedMessageService::new(two_session_store());
+
+ let ids = service
+ .message_ids(test_session())
+ .await
+ .expect("in-memory store cannot fail");
+ assert_eq!(
+ ids,
+ vec![
+ MessageId {
+ turn: TurnId(0),
+ author: AuthorKind::User,
+ },
+ MessageId {
+ turn: TurnId(0),
+ author: AuthorKind::Agent,
+ },
+ ],
+ "one turn, but both sides are separately addressable"
+ );
+
+ let unlogged = service
+ .message_ids(AgentSessionId::new_from_uuid(macro_uuid::Uuid::from_u128(
+ 9,
+ )))
+ .await
+ .expect("in-memory store cannot fail");
+ assert_eq!(unlogged, vec![]);
+}
diff --git a/crates/agent_fold/src/domain/test/util.rs b/crates/agent_fold/src/domain/test/util.rs
index 040505a31bf..54711cc5087 100644
--- a/crates/agent_fold/src/domain/test/util.rs
+++ b/crates/agent_fold/src/domain/test/util.rs
@@ -1,87 +1,4 @@
-//! Shared helpers for tests: parsing recorded protocol frames into log rows,
-//! and bridging `agent_session`'s in-memory test double to [`LogRepo`].
+//! Shared helpers for tests, re-exported from [`crate::testing`] so they are
+//! also available to crates that test against this one.
-use crate::domain::ports::LogRepo;
-use agent_session::domain::error::AgentSessionError;
-use agent_session::domain::model::{AgentSessionId, AgentSessionLog, Message};
-use agent_session::domain::ports::AgentSessionLogRepo;
-use agent_session::testing::InMemoryAgentSessionRepo;
-use macro_user_id::user_id::MacroUserIdStr;
-use std::collections::VecDeque;
-
-/// The trivial [`LogRepo`] this crate promises: `agent_session`'s in-memory
-/// test double already speaks
-/// [`AgentSessionLogRepo`](agent_session::domain::ports::AgentSessionLogRepo),
-/// so bridging it is one line. The real Postgres adapter gets the same
-/// treatment when it needs to answer [`FoldSession`](
-/// crate::domain::ports::FoldSession) queries.
-impl LogRepo for InMemoryAgentSessionRepo {
- async fn list_by_session(
- &self,
- session: AgentSessionId,
- ) -> Result, AgentSessionError> {
- let log = AgentSessionLogRepo::list_by_session(self, session).await?;
- Ok(log.into())
- }
-}
-
-/// The hermetic fixture: one complete turn with prose, a permission-gated
-/// terminal command, a patched-in edit, and a clean stop.
-pub const TURN: &str = include_str!("../../../fixtures/turn.jsonl");
-
-/// The session id fixture logs are parsed into by default.
-pub fn test_session() -> AgentSessionId {
- AgentSessionId::TEST_A
-}
-
-/// Parse one recorded line - the agent_session_recorder / fixture format:
-/// `{"ts": ..., "direction": "to_server" | "to_runtime", "content": {...}}` -
-/// into the log row the fold consumes.
-///
-/// Prompts get a user id attached, mirroring what the agent service stamps
-/// onto `agent_session_log.user_id` for user-originated ACP traffic.
-fn parse_line(session: AgentSessionId, line: &str) -> AgentSessionLog {
- let value: serde_json::Value = serde_json::from_str(line).expect("recorded line is JSON");
- let direction = value
- .get("direction")
- .and_then(|direction| direction.as_str())
- .expect("recorded line has a direction");
- let content = value.get("content").expect("recorded line has content");
-
- let is_prompt = content
- .get("method")
- .and_then(|method| method.as_str())
- .is_some_and(|method| method == "session/prompt");
-
- let content = match direction {
- "to_server" => Message::ToServer(
- serde_json::from_value(content.clone()).expect("to_server frame deserializes"),
- ),
- "to_runtime" => Message::ToRuntime(
- serde_json::from_value(content.clone()).expect("to_runtime frame deserializes"),
- ),
- other => panic!("unknown direction {other}"),
- };
-
- AgentSessionLog {
- agent_session_id: session,
- user_id: is_prompt.then(|| {
- MacroUserIdStr::try_from_email("eric@example.com").expect("test email parses")
- }),
- content,
- }
-}
-
-/// Parse a whole recording into the given session's log.
-pub fn parse_log_as(session: AgentSessionId, jsonl: &str) -> Vec {
- jsonl
- .lines()
- .filter(|line| !line.trim().is_empty())
- .map(|line| parse_line(session, line))
- .collect()
-}
-
-/// Parse a whole recording into [`test_session`]'s log.
-pub fn parse_log(jsonl: &str) -> Vec {
- parse_log_as(test_session(), jsonl)
-}
+pub use crate::testing::{InMemoryLog, TURN, parse_log, parse_log_as, test_session};
diff --git a/crates/agent_fold/src/lib.rs b/crates/agent_fold/src/lib.rs
index 479298dd5bd..1be3e0b7999 100644
--- a/crates/agent_fold/src/lib.rs
+++ b/crates/agent_fold/src/lib.rs
@@ -4,21 +4,24 @@
//!
//! An agent session is recorded as a flat log of Agent Runtime Protocol
//! frames - `agent_session_log`, modelled by
-//! [`agent_session::domain::model::AgentSessionLog`]. That log is faithful
-//! but unreadable: a single session is hundreds of streamed chunks,
-//! tool-call patches and token-usage reports. This crate collapses it into
-//! the handful of messages a person would recognize as the story of the
-//! session.
+//! [`domain::log::AgentSessionLog`]. That log is faithful but unreadable: a
+//! single session is hundreds of streamed chunks, tool-call patches and
+//! token-usage reports. This crate collapses it into the handful of messages
+//! a person would recognize as the story of the session.
+//!
+//! This crate is the bottom of the agent session stack: it owns the log
+//! vocabulary ([`domain::log`]) and the fold over it, and `agent_session` -
+//! which stores the log and orchestrates comms - depends on this crate, not
+//! the other way around.
//!
//! Nothing here is persisted. The fiction of the crate is that folded
//! messages live in a database: [`domain::ports::FoldedMessageRepo`] is a
//! repository-shaped query API, and
//! [`domain::service::FoldedMessageService`] answers it by delegating to
//! [`domain::ports::FoldSession`], which folds through
-//! [`domain::ports::LogRepo`] - a port this crate names itself, rather than
-//! depending on `agent_session`'s own. Because the messages are re-derived
-//! each time, the vocabulary in [`domain::model`] can change without a
-//! migration.
+//! [`domain::ports::LogRepo`] - the one capability it asks of whoever stores
+//! the log. Because the messages are re-derived each time, the vocabulary in
+//! [`domain::model`] can change without a migration.
//!
//! The domain speaks only its own vocabulary - turns, tool calls,
//! permissions - and names no display type. Rendering it as something a
@@ -26,9 +29,9 @@
//! whoever calls this crate.
//!
//! ```no_run
+//! use agent_fold::domain::log::AgentSessionId;
//! use agent_fold::domain::ports::{FoldedMessageRepo, LogRepo};
//! use agent_fold::domain::service::FoldedMessageService;
-//! use agent_session::domain::model::AgentSessionId;
//!
//! # async fn run(repo: R, session: AgentSessionId) -> Result<(), rootcause::Report>
//! # where R: LogRepo + Sync {
@@ -42,3 +45,7 @@
/// Domain models, the fold, and the query port.
pub mod domain;
+
+/// In-memory port implementations and recorded fixtures, for tests.
+#[cfg(any(test, feature = "test-utils"))]
+pub mod testing;
diff --git a/crates/agent_fold/src/testing.rs b/crates/agent_fold/src/testing.rs
new file mode 100644
index 00000000000..980c8cb3b4f
--- /dev/null
+++ b/crates/agent_fold/src/testing.rs
@@ -0,0 +1,125 @@
+//! In-memory port implementations and recorded fixtures for tests.
+//!
+//! Lets this crate's own tests - and crates that consume this one, like
+//! `agent_session` - fold real recorded protocol traffic without a database.
+
+use crate::domain::log::{AgentSessionId, AgentSessionLog, Message};
+use crate::domain::ports::LogRepo;
+use macro_user_id::user_id::MacroUserIdStr;
+use std::collections::{HashMap, VecDeque};
+use std::sync::{Arc, Mutex};
+
+/// An in-memory [`LogRepo`].
+///
+/// Cheap to clone - clones share one store, so a handle kept for appends
+/// sees reads made through the copy under test. Entries are returned in
+/// insertion order, the chronology the real repo gets from
+/// `ORDER BY created_at, id`.
+#[derive(Debug, Clone, Default)]
+pub struct InMemoryLog {
+ entries: Arc>>>,
+}
+
+impl InMemoryLog {
+ /// An empty log.
+ #[must_use]
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Append entries, in the order they should be read back.
+ pub fn extend(&self, entries: impl IntoIterator- ) {
+ let mut logs = self.entries.lock().expect("in-memory log is not poisoned");
+ for entry in entries {
+ logs.entry(entry.agent_session_id).or_default().push(entry);
+ }
+ }
+}
+
+impl FromIterator
for InMemoryLog {
+ fn from_iter>(entries: I) -> Self {
+ let log = Self::new();
+ log.extend(entries);
+ log
+ }
+}
+
+impl LogRepo for InMemoryLog {
+ async fn list_by_session(
+ &self,
+ session: AgentSessionId,
+ ) -> Result, rootcause::Report> {
+ Ok(self
+ .entries
+ .lock()
+ .expect("in-memory log is not poisoned")
+ .get(&session)
+ .cloned()
+ .unwrap_or_default()
+ .into())
+ }
+}
+
+/// The hermetic fixture: one complete turn with prose, a permission-gated
+/// terminal command, a patched-in edit, and a clean stop.
+pub const TURN: &str = include_str!("../fixtures/turn.jsonl");
+
+/// The session id fixture logs are parsed into by default.
+#[must_use]
+pub fn test_session() -> AgentSessionId {
+ AgentSessionId::new_from_uuid(macro_uuid::Uuid::from_u128(1))
+}
+
+/// Parse one recorded line - the agent_session_recorder / fixture format:
+/// `{"ts": ..., "direction": "to_server" | "to_runtime", "content": {...}}` -
+/// into the log row the fold consumes.
+///
+/// Prompts get a user id attached, mirroring what the agent service stamps
+/// onto `agent_session_log.user_id` for user-originated ACP traffic.
+fn parse_line(session: AgentSessionId, line: &str) -> AgentSessionLog {
+ let value: serde_json::Value = serde_json::from_str(line).expect("recorded line is JSON");
+ let direction = value
+ .get("direction")
+ .and_then(|direction| direction.as_str())
+ .expect("recorded line has a direction");
+ let content = value.get("content").expect("recorded line has content");
+
+ let is_prompt = content
+ .get("method")
+ .and_then(|method| method.as_str())
+ .is_some_and(|method| method == "session/prompt");
+
+ let content = match direction {
+ "to_server" => Message::ToServer(
+ serde_json::from_value(content.clone()).expect("to_server frame deserializes"),
+ ),
+ "to_runtime" => Message::ToRuntime(
+ serde_json::from_value(content.clone()).expect("to_runtime frame deserializes"),
+ ),
+ other => panic!("unknown direction {other}"),
+ };
+
+ AgentSessionLog {
+ agent_session_id: session,
+ user_id: is_prompt.then(|| {
+ MacroUserIdStr::try_from_email("eric@example.com").expect("test email parses")
+ }),
+ content,
+ }
+}
+
+/// Parse a whole recording into the given session's log.
+#[must_use]
+pub fn parse_log_as(session: AgentSessionId, jsonl: &str) -> Vec {
+ jsonl
+ .lines()
+ .filter(|line| !line.trim().is_empty())
+ .map(|line| parse_line(session, line))
+ .collect()
+}
+
+/// Parse a whole recording into [`test_session`]'s log.
+#[must_use]
+pub fn parse_log(jsonl: &str) -> Vec {
+ parse_log_as(test_session(), jsonl)
+}
diff --git a/crates/agent_harness/Cargo.toml b/crates/agent_harness/Cargo.toml
index aa26ae54c63..6de91251fed 100644
--- a/crates/agent_harness/Cargo.toml
+++ b/crates/agent_harness/Cargo.toml
@@ -37,6 +37,7 @@ url = { workspace = true }
workspace-hack = { version = "0.1", path = "../workspace-hack" }
[dev-dependencies]
+agent_fold = { path = "../agent_fold", features = ["test-utils"] }
agent_session = { path = "../agent_session", features = ["test-utils"] }
bot_id = { path = "../bot_id", features = ["test-utils"] }
mockall = { workspace = true }
diff --git a/crates/agent_harness/src/domain/ports/test.rs b/crates/agent_harness/src/domain/ports/test.rs
index 4156728bdd2..07d32d08ad8 100644
--- a/crates/agent_harness/src/domain/ports/test.rs
+++ b/crates/agent_harness/src/domain/ports/test.rs
@@ -3,13 +3,14 @@ use std::sync::Arc;
use agent_client_protocol::schema::v1::{
ClientRequest, ContentBlock, InitializeResponse, NewSessionResponse,
};
+use agent_fold::domain::service::FoldedMessageService;
use agent_runtime_protocol::domain::action::AgentAction;
use agent_session::PROTOCOL_VERSION;
use agent_session::domain::error::AgentSessionError;
use agent_session::domain::model::{AgentSessionId, CreateAgentSessionParams, Message};
use agent_session::domain::ports::AgentSessionLogRepo;
use agent_session::domain::service::{AgentSessionService, AgentSessionServiceImpl};
-use agent_session::testing::InMemoryAgentSessionRepo;
+use agent_session::testing::{InMemoryAgentSessionRepo, RecordingComms};
use bot_id::BotId;
use macro_user_id::user_id::MacroUserIdStr;
@@ -50,7 +51,11 @@ fn prompt_texts(container: &ContainerMock) -> Vec> {
async fn container_session_runs_and_logs_end_to_end() {
let id = AgentSessionId::TEST_A;
let store = InMemoryAgentSessionRepo::new();
- let sessions = Arc::new(AgentSessionServiceImpl::new(store.clone()));
+ let sessions = Arc::new(AgentSessionServiceImpl::new(
+ store.clone(),
+ FoldedMessageService::new(store.clone()),
+ RecordingComms::new(),
+ ));
let containers = MockContainerManager::new();
let container = containers
.spawn(SpawnContainer {
@@ -115,11 +120,81 @@ async fn container_session_runs_and_logs_end_to_end() {
);
}
+/// A live session's log is written by its actor, not through
+/// `append_event`, so the placeholder sync has to hang off that path too -
+/// otherwise a connected session streams frames into its log while its
+/// channel stays empty.
+#[tokio::test]
+async fn a_live_sessions_frames_place_holders_in_its_channel() {
+ let id = AgentSessionId::TEST_A;
+ let store = InMemoryAgentSessionRepo::new();
+ let comms = RecordingComms::new();
+ let sessions = Arc::new(AgentSessionServiceImpl::new(
+ store.clone(),
+ FoldedMessageService::new(store.clone()),
+ comms.clone(),
+ ));
+ let containers = MockContainerManager::new();
+ let container = containers
+ .spawn(SpawnContainer {
+ session_id: id,
+ repo_url: "https://github.com/macro/macro".to_owned(),
+ })
+ .await
+ .unwrap();
+ let agent = container.agent();
+
+ let record = sessions
+ .create_session(params(id), container.clone())
+ .await
+ .unwrap();
+ assert!(
+ comms.created().is_empty(),
+ "a session with an empty log folds to nothing"
+ );
+
+ let send = tokio::spawn({
+ let sessions = sessions.clone();
+ async move {
+ sessions
+ .send_action(id, Some(owner()), AgentAction::prompt("fix the flaky test"))
+ .await
+ }
+ });
+
+ container.sends_ready();
+ agent.wait_for_requests(1).await;
+ agent.completes_initialize(InitializeResponse::new(PROTOCOL_VERSION));
+ agent.wait_for_requests(2).await;
+ agent.opens_session(NewSessionResponse::new("acp-placeholder-test"));
+ agent.wait_for_requests(3).await;
+
+ // Delivery completes only after the prompt's log write, and the sync runs
+ // inside that write - so there is nothing to wait on here.
+ send.await.unwrap().unwrap();
+
+ let created = comms.created();
+ assert!(
+ !created.is_empty(),
+ "the prompt the actor logged should have produced a placeholder"
+ );
+ assert!(
+ created
+ .iter()
+ .all(|(channel, _)| *channel == record.channel_id),
+ "placeholders belong to the session's dedicated channel"
+ );
+}
+
#[tokio::test]
async fn attaching_a_second_transport_to_an_active_session_fails() {
let id = AgentSessionId::TEST_A;
let store = InMemoryAgentSessionRepo::new();
- let sessions = AgentSessionServiceImpl::new(store);
+ let sessions = AgentSessionServiceImpl::new(
+ store.clone(),
+ FoldedMessageService::new(store),
+ RecordingComms::new(),
+ );
let first = ContainerMock::default();
let second = ContainerMock::default();
diff --git a/crates/agent_harness/src/domain/service/test.rs b/crates/agent_harness/src/domain/service/test.rs
index 86f21136e0d..39b519d2f2e 100644
--- a/crates/agent_harness/src/domain/service/test.rs
+++ b/crates/agent_harness/src/domain/service/test.rs
@@ -6,11 +6,12 @@ use agent_client_protocol::schema::v1::{
AgentCapabilities, ClientRequest, ContentBlock, InitializeResponse, NewSessionResponse,
ResumeSessionResponse, SessionCapabilities, SessionId, SessionResumeCapabilities,
};
+use agent_fold::domain::service::FoldedMessageService;
use agent_session::PROTOCOL_VERSION;
use agent_session::domain::model::{AgentSessionId, CreateAgentSessionParams, Message};
use agent_session::domain::ports::{AgentSessionLogRepo as _, AgentSessionRepo as _};
use agent_session::domain::service::AgentSessionServiceImpl;
-use agent_session::testing::InMemoryAgentSessionRepo;
+use agent_session::testing::{InMemoryAgentSessionRepo, RecordingComms};
use bot_id::BotId;
use macro_user_id::user_id::MacroUserIdStr;
@@ -44,7 +45,11 @@ fn open_command() -> OpenSession {
fn harness() -> (
AgentHarnessService<
- AgentSessionServiceImpl,
+ AgentSessionServiceImpl<
+ InMemoryAgentSessionRepo,
+ FoldedMessageService,
+ RecordingComms,
+ >,
MockContainerManager,
AnnouncerMock,
>,
@@ -56,7 +61,11 @@ fn harness() -> (
let containers = MockContainerManager::new();
let announcer = AnnouncerMock::new();
let service = AgentHarnessService::new(
- AgentSessionServiceImpl::new(repo.clone()),
+ AgentSessionServiceImpl::new(
+ repo.clone(),
+ FoldedMessageService::new(repo.clone()),
+ RecordingComms::new(),
+ ),
containers.clone(),
announcer.clone(),
SessionDefaults {
diff --git a/crates/agent_session/Cargo.toml b/crates/agent_session/Cargo.toml
index 3cdee402cb1..14a9a2f6269 100644
--- a/crates/agent_session/Cargo.toml
+++ b/crates/agent_session/Cargo.toml
@@ -6,7 +6,7 @@ publish = false
[features]
# Mock and in-memory port implementations, for crates that test against this one.
-test-utils = ["dep:mockall"]
+test-utils = ["dep:mockall", "agent_fold/test-utils"]
[dependencies]
@@ -14,14 +14,17 @@ agent-client-protocol = { workspace = true }
dashmap = { workspace = true }
macro_uuid = { path = "../macro_uuid" }
mockall = { workspace = true, optional = true }
+agent_fold = { path = "../agent_fold" }
agent_runtime_protocol = { path = "../agent_runtime_protocol" }
axum = { workspace = true }
bots = { path = "../bots"}
+clap = { workspace = true }
macro_authorization = { path = "../macro_authorization", default-features = false, features = [
"axum",
] }
macro_user_id = { path = "../macro_user_id", features = ["sqlx"] }
utoipa = { workspace = true }
+rootcause = { workspace = true }
sqlx = { workspace = true }
chrono = { workspace = true }
thiserror = { workspace = true }
@@ -34,6 +37,8 @@ tracing = { workspace = true }
workspace-hack = { version = "0.1", path = "../workspace-hack" }
[dev-dependencies]
+non_empty = { path = "../non_empty" }
+agent_fold = { path = "../agent_fold", features = ["test-utils"] }
axum = { workspace = true, features = ["ws"] }
macro_db_migrator = { path = "../macro_db_migrator" }
tokio = { workspace = true, features = ["fs", "io-std", "io-util", "macros", "net", "rt-multi-thread", "time"] }
diff --git a/crates/agent_session/src/bin/seed_jsonl.rs b/crates/agent_session/src/bin/seed_jsonl.rs
new file mode 100644
index 00000000000..a667b4f9cdb
--- /dev/null
+++ b/crates/agent_session/src/bin/seed_jsonl.rs
@@ -0,0 +1,301 @@
+//! Seed an agent session into Postgres from a recorded JSONL session.
+//!
+//! The sibling [`fold_jsonl`] binary in `agent_fold` folds a recording in
+//! memory and prints it; this one persists the same recording so the session
+//! can be read back through the ordinary service - useful for putting a real
+//! transcript in front of the UI without running a container.
+//!
+//! Frames go in through
+//! [`AgentSessionService::append_event`](agent_session::domain::service::AgentSessionService::append_event)
+//! rather than the log repo, because appending is not just an insert: each
+//! append refolds the session and writes a placeholder comms message for any
+//! turn the fold now derives that its channel has not seen. Writing to the
+//! repo directly stores the log but leaves the channel empty, so the session
+//! renders as nothing.
+//!
+//! Recordings are the ones written by the `agent_session_recorder` example to
+//! `~/.agent_runtime_sessions/.jsonl`: one JSON object per line,
+//! carrying this crate's [`Message`] serialization plus a recorder timestamp
+//! that is ignored here.
+//!
+//! ```text
+//! {"ts": "...", "direction": "to_server" | "to_runtime", "content": }
+//! ```
+//!
+//! ```sh
+//! cargo run -p agent_session --bin seed_jsonl -- \
+//! ~/.agent_runtime_sessions/.jsonl \
+//! --database-url postgres://user:password@localhost:5432/macrodb
+//! ```
+//!
+//! [`fold_jsonl`]: https://docs.rs/agent_fold
+
+use agent_fold::domain::service::FoldedMessageService;
+use agent_session::domain::model::{
+ AgentSessionId, AgentSessionLog, CreateAgentSessionParams, Message,
+};
+use agent_session::domain::ports::AgentSessionRepo;
+use agent_session::domain::service::{AgentSessionService, AgentSessionServiceImpl};
+use agent_session::outbound::postgres::PgAgentSessionRepo;
+use bots::domain::models::BotId;
+use clap::Parser;
+use macro_user_id::user_id::MacroUserIdStr;
+use macro_uuid::Uuid;
+use serde::Deserialize;
+use sqlx::postgres::PgPoolOptions;
+use std::path::{Path, PathBuf};
+use std::process::ExitCode;
+use std::time::{Duration, Instant};
+
+/// The `Macro Coder` system bot, seeded by migration, which owns agent
+/// sessions unless the caller names a different bot.
+const MACRO_CODER_BOT_ID: &str = "00000000-0000-0000-0000-00000000a9e7";
+
+/// Seed an agent session into Postgres from a recorded JSONL session.
+#[derive(Parser)]
+struct Args {
+ /// Path to a session recording, e.g. ~/.agent_runtime_sessions/.jsonl
+ recording: PathBuf,
+
+ /// Postgres the session is written to.
+ #[arg(
+ short,
+ long,
+ default_value = "postgres://user:password@localhost:5432/macrodb"
+ )]
+ database_url: String,
+
+ /// User who owns the dedicated agent channel the session creates.
+ #[arg(long, default_value = "macro|dev@macro.com")]
+ owner: String,
+
+ /// Bot the session runs as.
+ #[arg(long, default_value = MACRO_CODER_BOT_ID)]
+ bot_id: Uuid,
+
+ /// Model slug recorded on the session.
+ #[arg(long, default_value = "claude-opus-5")]
+ model: String,
+
+ /// Harness slug recorded on the session.
+ #[arg(long, default_value = "claude-code")]
+ harness: String,
+
+ /// Repository the session is nominally working in.
+ #[arg(long, default_value = "https://github.com/macro/cloud-storage")]
+ repo_url: String,
+}
+
+/// Why seeding failed.
+#[derive(Debug, thiserror::Error)]
+enum SeedError {
+ /// The recording could not be read.
+ #[error("failed to read recording")]
+ Io(#[from] std::io::Error),
+ /// A line was not a well-formed recorded frame.
+ #[error("line {line} is not a recorded frame")]
+ Frame {
+ /// The offending line, 1-based.
+ line: usize,
+ /// What failed to parse.
+ #[source]
+ source: serde_json::Error,
+ },
+ /// The owner was not a valid Macro user id.
+ #[error("invalid owner id")]
+ Owner(#[source] anyhow::Error),
+ /// Postgres could not be reached.
+ #[error("failed to connect to {url}")]
+ Connect {
+ /// The database that could not be reached.
+ url: String,
+ /// What the driver reported.
+ #[source]
+ source: sqlx::Error,
+ },
+ /// The session row (and its channel) could not be written. Transparent
+ /// because the repository's own context already names the failure.
+ #[error(transparent)]
+ CreateSession(#[from] agent_session::domain::error::AgentSessionError),
+ /// A log entry could not be written.
+ #[error("failed to write log entry {entry} of {total}")]
+ WriteLog {
+ /// The entry that failed, 1-based.
+ entry: usize,
+ /// How many entries the recording held.
+ total: usize,
+ /// What the repository reported.
+ #[source]
+ source: agent_session::domain::error::AgentSessionError,
+ },
+}
+
+/// One line of a recording, as the recorder writes it.
+///
+/// [`Message`] is adjacently tagged on exactly these two fields, but the
+/// recorder also flattens a `ts` in, so the line is taken apart here and the
+/// frame rebuilt from the parts rather than deserialized straight into
+/// [`Message`].
+#[derive(Deserialize)]
+struct RecordedLine {
+ direction: Direction,
+ content: serde_json::Value,
+ /// Present when a user, rather than the runtime, originated the frame.
+ #[serde(default)]
+ user_id: Option,
+}
+
+/// Which way the recorded frame was travelling.
+#[derive(Deserialize)]
+#[serde(rename_all = "snake_case")]
+enum Direction {
+ ToServer,
+ ToRuntime,
+}
+
+/// Read a recording into log entries for `session`, in file order.
+///
+/// Blank lines are skipped; anything else must parse as a recorded frame.
+fn read_recording(path: &Path, session: AgentSessionId) -> Result, SeedError> {
+ let jsonl = std::fs::read_to_string(path)?;
+ jsonl
+ .lines()
+ .enumerate()
+ .filter(|(_, line)| !line.trim().is_empty())
+ .map(|(index, line)| parse_line(session, index + 1, line))
+ .collect()
+}
+
+/// Parse one recorded line into a log entry for the given session.
+fn parse_line(
+ session: AgentSessionId,
+ line_number: usize,
+ line: &str,
+) -> Result {
+ let frame = |source| SeedError::Frame {
+ line: line_number,
+ source,
+ };
+ let recorded: RecordedLine = serde_json::from_str(line).map_err(frame)?;
+ let content = match recorded.direction {
+ Direction::ToServer => {
+ Message::ToServer(serde_json::from_value(recorded.content).map_err(frame)?)
+ }
+ Direction::ToRuntime => {
+ Message::ToRuntime(serde_json::from_value(recorded.content).map_err(frame)?)
+ }
+ };
+ let user_id = recorded
+ .user_id
+ .map(MacroUserIdStr::try_from)
+ .transpose()
+ .map_err(|_| SeedError::Frame {
+ line: line_number,
+ source: serde::de::Error::custom("user_id is not a Macro user id"),
+ })?;
+ Ok(AgentSessionLog {
+ agent_session_id: session,
+ user_id,
+ content,
+ })
+}
+
+#[tokio::main(flavor = "current_thread")]
+async fn main() -> ExitCode {
+ let args = Args::parse();
+ match seed(&args).await {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(error) => fail(&error),
+ }
+}
+
+/// Persist the recording as a fresh agent session.
+///
+/// The session id is minted up front so the whole recording can be parsed -
+/// and rejected on the first bad line - before anything is written.
+async fn seed(args: &Args) -> Result<(), SeedError> {
+ let session_id = AgentSessionId::new();
+ let log = read_recording(&args.recording, session_id)?;
+ let owner = MacroUserIdStr::try_from(args.owner.clone())
+ .map_err(|error| SeedError::Owner(anyhow::anyhow!("{error}")))?;
+
+ let pool = PgPoolOptions::new()
+ .max_connections(1)
+ .acquire_timeout(Duration::from_secs(10))
+ .connect(&args.database_url)
+ .await
+ .map_err(|source| SeedError::Connect {
+ url: args.database_url.clone(),
+ source,
+ })?;
+ // The same repo answers all three ports, exactly as the composition root
+ // in `document_storage_service` wires them.
+ let repo = PgAgentSessionRepo::new(pool);
+ let service = AgentSessionServiceImpl::new(
+ repo.clone(),
+ FoldedMessageService::new(repo.clone()),
+ repo.clone(),
+ );
+
+ // Straight to the repo rather than `create_session`: that attaches a
+ // transport, and a recording has no container to attach to. The row is
+ // all a seeded session needs - its frames arrive from the file below.
+ let session = repo
+ .create(CreateAgentSessionParams {
+ id: session_id,
+ owner_id: owner,
+ bot_id: BotId::new_from_uuid(args.bot_id),
+ thread_id: None,
+ originating_message_id: None,
+ model: args.model.clone(),
+ harness: args.harness.clone(),
+ repo_url: args.repo_url.clone(),
+ })
+ .await
+ .map_err(SeedError::CreateSession)?;
+
+ // Frames go in one at a time and in order: `agent_session_log` stamps
+ // `created_at` itself and the log is read back `ORDER BY created_at, id`,
+ // so append order is what makes the recording replay as recorded.
+ //
+ // Each append refolds the whole session, so seeding a recording of n
+ // frames folds O(n^2) entries. That is the service's documented "fine for
+ // now" tradeoff for live appends; here it is the dominant cost, hence the
+ // progress line.
+ let total = log.len();
+ let started = Instant::now();
+ for (index, entry) in log.into_iter().enumerate() {
+ service
+ .append_event(entry)
+ .await
+ .map_err(|source| SeedError::WriteLog {
+ entry: index + 1,
+ total,
+ source,
+ })?;
+ if (index + 1) % 250 == 0 || index + 1 == total {
+ eprintln!(
+ " {} / {total} frames ({:.1?})",
+ index + 1,
+ started.elapsed()
+ );
+ }
+ }
+
+ println!("session: {}", session.id);
+ println!("channel: {}", session.channel_id);
+ println!("entries: {total}");
+ Ok(())
+}
+
+/// Print a failure and its cause chain.
+fn fail(error: &dyn std::error::Error) -> ExitCode {
+ eprint!("error: {error}");
+ let mut cause = error.source();
+ while let Some(source) = cause {
+ eprint!(": {source}");
+ cause = source.source();
+ }
+ eprintln!();
+ ExitCode::FAILURE
+}
diff --git a/crates/agent_session/src/bin/sync_placeholders.rs b/crates/agent_session/src/bin/sync_placeholders.rs
new file mode 100644
index 00000000000..260a7031afb
--- /dev/null
+++ b/crates/agent_session/src/bin/sync_placeholders.rs
@@ -0,0 +1,88 @@
+//! Rebuild the comms placeholder messages of one or more agent sessions.
+//!
+//! Placeholders are derived: a session's channel rows carry no content of
+//! their own, only the composite id of the folded message they render. So
+//! they can be dropped and rebuilt from `agent_session_log` at any time,
+//! which is what this does - after a migration that changes how they are
+//! keyed, or any time a channel has drifted from its log.
+//!
+//! Missing placeholders are created; existing ones are left alone. Deleting
+//! the stale rows first is the caller's job, and safe:
+//!
+//! ```sh
+//! psql "$DB" -c "DELETE FROM comms_messages WHERE agent_session_message_id IS NOT NULL"
+//! psql "$DB" -At -c "SELECT id FROM agent_session" \
+//! | xargs cargo run --release -p agent_session --bin sync_placeholders -- -d "$DB"
+//! ```
+
+use agent_fold::domain::service::FoldedMessageService;
+use agent_session::domain::model::AgentSessionId;
+use agent_session::domain::service::{AgentSessionService, AgentSessionServiceImpl};
+use agent_session::outbound::postgres::PgAgentSessionRepo;
+use clap::Parser;
+use macro_uuid::Uuid;
+use sqlx::postgres::PgPoolOptions;
+use std::process::ExitCode;
+use std::time::{Duration, Instant};
+
+/// Rebuild the comms placeholder messages of one or more agent sessions.
+#[derive(Parser)]
+struct Args {
+ /// Sessions to rebuild. Every session is folded from scratch.
+ #[arg(required = true)]
+ sessions: Vec,
+
+ /// Postgres holding the sessions.
+ #[arg(
+ short,
+ long,
+ default_value = "postgres://user:password@localhost:5432/macrodb"
+ )]
+ database_url: String,
+}
+
+#[tokio::main(flavor = "current_thread")]
+async fn main() -> ExitCode {
+ let args = Args::parse();
+
+ let pool = match PgPoolOptions::new()
+ .max_connections(1)
+ .acquire_timeout(Duration::from_secs(10))
+ .connect(&args.database_url)
+ .await
+ {
+ Ok(pool) => pool,
+ Err(error) => {
+ eprintln!("error: cannot connect to {}: {error}", args.database_url);
+ return ExitCode::FAILURE;
+ }
+ };
+
+ // The same repo answers all three ports, as the composition root in
+ // `document_storage_service` wires them.
+ let repo = PgAgentSessionRepo::new(pool);
+ let service =
+ AgentSessionServiceImpl::new(repo.clone(), FoldedMessageService::new(repo.clone()), repo);
+
+ let mut failed = 0usize;
+ for id in &args.sessions {
+ let started = Instant::now();
+ match service
+ .sync_placeholders(AgentSessionId::new_from_uuid(*id))
+ .await
+ {
+ Ok(()) => println!("{id} ok ({:.1?})", started.elapsed()),
+ Err(error) => {
+ failed += 1;
+ eprintln!("{id} failed: {error}");
+ }
+ }
+ }
+
+ if failed == 0 {
+ ExitCode::SUCCESS
+ } else {
+ eprintln!("{failed} of {} sessions failed", args.sessions.len());
+ ExitCode::FAILURE
+ }
+}
diff --git a/crates/agent_session/src/domain/error.rs b/crates/agent_session/src/domain/error.rs
index 7d8523b23b8..c7342014240 100644
--- a/crates/agent_session/src/domain/error.rs
+++ b/crates/agent_session/src/domain/error.rs
@@ -24,4 +24,14 @@ pub enum AgentSessionError {
Acp(#[from] agent_client_protocol::Error),
#[error("{0}")]
Unknown(#[from] anyhow::Error),
+ /// A fold or comms step failed while keeping a session's channel in step
+ /// with its log.
+ #[error("{0}")]
+ Fold(rootcause::Report),
+}
+
+impl From for AgentSessionError {
+ fn from(report: rootcause::Report) -> Self {
+ Self::Fold(report)
+ }
}
diff --git a/crates/agent_session/src/domain/model.rs b/crates/agent_session/src/domain/model.rs
index 2ba6cd9e3a8..9f8dedea018 100644
--- a/crates/agent_session/src/domain/model.rs
+++ b/crates/agent_session/src/domain/model.rs
@@ -1,42 +1,19 @@
-use agent_runtime_protocol::domain::schema::v0::{SystemEvent, ToRuntimeMessage, ToServerMessage};
+use agent_fold::domain::model::FoldedMessage;
+use agent_runtime_protocol::domain::schema::v0::SystemEvent;
use bots::domain::models::BotId;
use chrono::{DateTime, Utc};
use macro_user_id::user_id::MacroUserIdStr;
use macro_uuid::Uuid;
-use serde::{Deserialize, Serialize};
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
-#[serde(transparent)]
-pub struct AgentSessionId(Uuid);
-
-impl AgentSessionId {
- #[cfg(any(test, feature = "test-utils"))]
- pub const TEST_A: Self = Self(Uuid::from_u128(0xA));
-
- #[cfg(any(test, feature = "test-utils"))]
- pub const TEST_B: Self = Self(Uuid::from_u128(0xB));
-
- /// Mint a fresh session id, backed by a UUIDv7.
- #[expect(clippy::new_without_default, reason = "each call mints a distinct id")]
- pub fn new() -> Self {
- Self(macro_uuid::generate_uuid_v7())
- }
-
- pub(crate) fn new_from_uuid(id: Uuid) -> Self {
- Self(id)
- }
-
- /// The underlying UUID.
- pub fn as_uuid(&self) -> Uuid {
- self.0
- }
-}
-
-impl std::fmt::Display for AgentSessionId {
- fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- self.0.fmt(formatter)
- }
-}
+// The log vocabulary - the session id, the log entry, and the frame it
+// carries - is owned by `agent_fold`, the bottom of the agent session stack,
+// so that this crate can depend on the fold (see `agent_fold::domain::log`).
+// Re-exported here because this is where callers expect session types.
+pub use agent_fold::domain::log::{AgentSessionId, AgentSessionLog, Message};
+// Folded messages are derived, but a `MessageId` is also what a comms
+// placeholder persists (inside its `agent_session_message_id`) to say which
+// message it renders.
+pub use agent_fold::domain::model::{Author, AuthorKind, MessageId, TurnId};
#[derive(Debug, Clone, Default, strum::AsRefStr)]
#[strum(serialize_all = "snake_case")]
@@ -97,6 +74,41 @@ pub struct AgentSession {
pub modified_at: DateTime,
}
+/// The composite id a placeholder comms message stores in its
+/// `agent_session_message_id` column:
+/// `"{agent_session_id}:{turn}:{author}"`.
+///
+/// Folded messages have no table of their own, so this composite is the whole
+/// mapping between a comms row and the message it renders. Comms writes it
+/// when placing a placeholder, and readers joining folded messages back onto
+/// comms rows reproduce it from the same parts.
+///
+/// Keyed per message rather than per turn: a turn yields a prompt and a
+/// reply with different senders, and each needs its own row.
+#[must_use]
+pub fn composite_message_id(session: AgentSessionId, id: MessageId) -> String {
+ format!("{}:{id}", session.as_uuid())
+}
+
+/// The message key inside a [`composite_message_id`] built for `session`, or
+/// `None` when the composite names a different session or is malformed.
+#[must_use]
+pub fn parse_composite_message_id(session: AgentSessionId, composite: &str) -> Option {
+ composite
+ .strip_prefix(&format!("{}:", session.as_uuid()))?
+ .parse()
+ .ok()
+}
+
+/// A session's folded messages, looked up by its dedicated channel.
+#[derive(Debug, Clone)]
+pub struct ChannelFoldedMessages {
+ /// The session whose log derived the messages.
+ pub agent_session_id: AgentSessionId,
+ /// The folded messages, oldest first.
+ pub messages: Vec,
+}
+
/// How an incoming channel context relates to an agent session.
#[derive(Debug, Clone)]
pub enum ChannelSession {
@@ -119,24 +131,3 @@ pub enum ChannelSession {
subthread_agent_session: AgentSession,
},
}
-
-/// One logical protocol message with its direction.
-///
-/// Serializes as `{"direction": "to_server" | "to_runtime", "content": }`,
-/// the same vocabulary the Postgres log storage uses for its `direction` and
-/// `content` columns, so recorded fixtures and stored rows share one wire
-/// format.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(tag = "direction", content = "content", rename_all = "snake_case")]
-pub enum Message {
- ToServer(ToServerMessage),
- ToRuntime(ToRuntimeMessage),
-}
-
-#[derive(Debug, Clone)]
-pub struct AgentSessionLog {
- pub agent_session_id: AgentSessionId,
- /// if this is ACP sent by a user this will be Some
- pub user_id: Option>,
- pub content: Message,
-}
diff --git a/crates/agent_session/src/domain/ports.rs b/crates/agent_session/src/domain/ports.rs
index f1f7deb932d..e80c06cdf1b 100644
--- a/crates/agent_session/src/domain/ports.rs
+++ b/crates/agent_session/src/domain/ports.rs
@@ -6,6 +6,7 @@ use macro_uuid::Uuid;
use super::error::Result;
use super::model::*;
+use std::collections::HashSet;
/// A bidirectional connection to an agent runtime.
pub trait AgentConnector:
@@ -82,3 +83,37 @@ pub trait AgentSessionLogRepo: Send + Sync + 'static {
agent_session_id: AgentSessionId,
) -> impl Future>> + Send;
}
+
+/// Writing agent-message placeholder rows into comms.
+///
+/// A placeholder is a comms message with no stored body whose
+/// `agent_session_message_id` - the composite
+/// `"{agent_session_id}:{turn}:{author}"` - names the folded message of an
+/// agent session it renders. The service diffs
+/// [`Comms::messages_with_placeholders`] against what the fold derives
+/// ([`FoldedMessageRepo::message_ids`](agent_fold::domain::ports::FoldedMessageRepo::message_ids))
+/// to decide which placeholders are missing.
+///
+/// One placeholder per folded message, not per turn: a turn's prompt and its
+/// reply have different authors, so collapsing them onto one row would leave
+/// the prompt with no sender of its own.
+pub trait Comms {
+ /// The messages of this session that already have a placeholder row in
+ /// its channel.
+ fn messages_with_placeholders(
+ &self,
+ session: &AgentSession,
+ ) -> impl Future, rootcause::Report>> + Send;
+
+ /// Write a bodyless placeholder row to the session's channel, carrying
+ /// the given message key inside its `agent_session_message_id`.
+ ///
+ /// `author` sets the row's sender: the agent's messages are sent by the
+ /// session's bot, a user's by that user.
+ fn create_message_placeholder(
+ &self,
+ session: &AgentSession,
+ id: MessageId,
+ author: &Author,
+ ) -> impl Future> + Send;
+}
diff --git a/crates/agent_session/src/domain/service.rs b/crates/agent_session/src/domain/service.rs
index f150e7daa7d..4d41db48d6f 100644
--- a/crates/agent_session/src/domain/service.rs
+++ b/crates/agent_session/src/domain/service.rs
@@ -3,19 +3,38 @@
//! Inbound adapters depend on [`AgentSessionService`] rather than repository
//! ports. Protocol decisions live in [`super::session`]'s pure machine, and
//! each connection's effects are executed by its actor shell.
+//!
+//! Appending to a session's log is what keeps its channel in step with it:
+//! every append refolds the session - through `agent_fold`'s
+//! [`FoldedMessageRepo`] - and writes a placeholder comms message for each
+//! folded message the log now derives that comms has not seen.
+//!
+//! A live session's log is written by its actor rather than through
+//! [`AgentSessionService::append_event`], so the actor is handed a
+//! [`PlaceholderSyncingLogs`] instead of the bare repository. That is what
+//! makes the two paths agree: the actor keeps writing frames the only way it
+//! knows how, and placeholders appear either way.
+
+#[cfg(test)]
+mod test;
use std::sync::Arc;
use agent_client_protocol::schema::v1::SessionId;
+use agent_fold::domain::ports::FoldedMessageRepo;
use agent_runtime_protocol::domain::action::AgentAction;
use dashmap::DashMap;
use dashmap::mapref::entry::Entry;
use macro_user_id::user_id::MacroUserIdStr;
+use macro_uuid::Uuid;
use tokio::sync::{mpsc, oneshot};
use super::error::{AgentSessionError, Result};
-use super::model::{AgentSession, AgentSessionId, CreateAgentSessionParams};
-use super::ports::{AgentConnector, AgentSessionLogRepo, AgentSessionRepo};
+use super::model::{
+ AgentSession, AgentSessionId, AgentSessionLog, ChannelFoldedMessages, ChannelSession,
+ CreateAgentSessionParams,
+};
+use super::ports::{AgentConnector, AgentSessionLogRepo, AgentSessionRepo, Comms};
use super::session::actors::{SessionActor, SessionCommand, Stepped};
/// Buffered not-yet-accepted commands per session actor.
@@ -60,19 +79,53 @@ pub trait AgentSessionService: Send + Sync + 'static {
user_id: Option>,
action: AgentAction,
) -> impl Future> + Send;
+
+ /// Append a protocol event to a session's log, creating a placeholder
+ /// comms message for every folded message the log now derives that does
+ /// not have one yet.
+ ///
+ /// This is the path for a session with no live actor - seeding a log from
+ /// a recording, or replaying one. A connected session's frames reach the
+ /// same place through its actor; see the module docs.
+ fn append_event(&self, log: AgentSessionLog) -> impl Future> + Send;
+
+ /// Bring a session's channel back in step with its log without appending
+ /// anything, creating whatever placeholders are missing.
+ ///
+ /// Placeholders are derived, never authoritative, so they can be dropped
+ /// and rebuilt from the log at any time - which is what this is for.
+ fn sync_placeholders(&self, session: AgentSessionId)
+ -> impl Future> + Send;
+
+ /// The folded messages of the agent session behind a channel, or `None`
+ /// when no session owns the channel.
+ fn channel_messages(
+ &self,
+ channel_id: Uuid,
+ ) -> impl Future>> + Send;
}
/// Agent session service backed by one durable repository and local actors.
-pub struct AgentSessionServiceImpl {
+///
+/// `R` is the persistence adapter implementing both [`AgentSessionRepo`] and
+/// [`AgentSessionLogRepo`], e.g. `outbound::postgres::PgAgentSessionRepo`.
+/// `Folds` answers "what messages does this session's log derive" -
+/// `agent_fold` folding the log on read - and `C` writes placeholder messages
+/// into the session's channel.
+pub struct AgentSessionServiceImpl {
repo: R,
+ folds: Folds,
+ comms: C,
active: Arc,
}
-impl AgentSessionServiceImpl {
- /// Build a service from a repository implementing both session ports.
- pub fn new(repo: R) -> Self {
+impl AgentSessionServiceImpl {
+ /// Build a service from its persistence port, fold, and comms writer.
+ pub fn new(repo: R, folds: Folds, comms: C) -> Self {
Self {
repo,
+ folds,
+ comms,
active: Arc::new(DashMap::new()),
}
}
@@ -85,6 +138,8 @@ impl AgentSessionServiceImpl {
) -> Result<()>
where
R: AgentSessionRepo + AgentSessionLogRepo + Clone,
+ Folds: FoldedMessageRepo + Clone + Send + Sync + 'static,
+ C: Comms + Clone + Send + Sync + 'static,
Connector: AgentConnector + Clone,
{
let (commands, command_rx) = mpsc::channel(COMMAND_BUFFER);
@@ -96,7 +151,14 @@ impl AgentSessionServiceImpl {
}
}
- let actor = SessionActor::new(id, acp_session_id, connector, self.repo.clone(), command_rx);
+ // The actor owns this session's log writes, so it writes through the
+ // placeholder sync rather than the bare repository - see module docs.
+ let logs = PlaceholderSyncingLogs {
+ repo: self.repo.clone(),
+ folds: self.folds.clone(),
+ comms: self.comms.clone(),
+ };
+ let actor = SessionActor::new(id, acp_session_id, connector, logs, command_rx);
tokio::spawn(run_session(actor, Arc::downgrade(&self.active), commands));
Ok(())
}
@@ -138,9 +200,11 @@ impl AgentSessionServiceImpl {
}
}
-impl AgentSessionService for AgentSessionServiceImpl
+impl AgentSessionService for AgentSessionServiceImpl
where
R: AgentSessionRepo + AgentSessionLogRepo + Clone,
+ Folds: FoldedMessageRepo + Clone + Send + Sync + 'static,
+ C: Comms + Clone + Send + Sync + 'static,
{
async fn create_session(
&self,
@@ -191,6 +255,164 @@ where
) -> Result<()> {
self.deliver_action(id, user_id, action).await
}
+
+ #[tracing::instrument(err, skip(self, log))]
+ async fn append_event(&self, log: AgentSessionLog) -> Result<()> {
+ let session_id = log.agent_session_id;
+ AgentSessionLogRepo::create(&self.repo, log).await?;
+ self.sync_placeholders(session_id).await
+ }
+
+ #[tracing::instrument(err, skip(self))]
+ async fn sync_placeholders(&self, session_id: AgentSessionId) -> Result<()> {
+ create_missing_placeholders(&self.repo, &self.folds, &self.comms, session_id).await
+ }
+
+ #[tracing::instrument(err, skip(self))]
+ async fn channel_messages(&self, channel_id: Uuid) -> Result> {
+ // A channel load carries no thread or bot context, so only the
+ // dedicated-channel relation can match; thread-scoped sessions are
+ // rendered by their own dedicated channels.
+ let session = match self.repo.find_for_channel(channel_id, None, None).await? {
+ ChannelSession::None => return Ok(None),
+ ChannelSession::InDedicatedChannel(session)
+ | ChannelSession::CreatedFromThread(session) => session,
+ ChannelSession::ThreadInDedicatedChannel {
+ dedicated_channel_agent_session,
+ ..
+ } => dedicated_channel_agent_session,
+ };
+
+ let messages = self.folds.messages(session.id).await?;
+ Ok(Some(ChannelFoldedMessages {
+ agent_session_id: session.id,
+ messages,
+ }))
+ }
+}
+
+/// The [`AgentSessionLogRepo`] a session's actor writes through: the durable
+/// append, then the placeholder sync that keeps the session's channel in step
+/// with what its log now derives.
+///
+/// A connected session's frames never pass through
+/// [`AgentSessionService::append_event`] - the actor owns the log write - so
+/// without this the log would fill up while the channel stayed empty.
+#[derive(Clone)]
+struct PlaceholderSyncingLogs {
+ repo: R,
+ folds: Folds,
+ comms: C,
+}
+
+impl AgentSessionLogRepo for PlaceholderSyncingLogs
+where
+ R: AgentSessionRepo + AgentSessionLogRepo + Clone,
+ Folds: FoldedMessageRepo + Clone + Send + Sync + 'static,
+ C: Comms + Clone + Send + Sync + 'static,
+{
+ async fn create(&self, log: AgentSessionLog) -> Result<()> {
+ let session = log.agent_session_id;
+ AgentSessionLogRepo::create(&self.repo, log).await?;
+
+ // A failed sync must not fail the append. The actor treats a log
+ // error as fatal to the connection, and placeholders are derived and
+ // rebuildable (`sync_placeholders`) - killing a live session over a
+ // projection it can recreate would be the wrong trade.
+ if let Err(error) =
+ create_missing_placeholders(&self.repo, &self.folds, &self.comms, session).await
+ {
+ tracing::error!(
+ error = ?error,
+ %session,
+ "failed to sync agent session placeholders"
+ );
+ }
+ Ok(())
+ }
+
+ async fn list_by_session(&self, session: AgentSessionId) -> Result> {
+ AgentSessionLogRepo::list_by_session(&self.repo, session).await
+ }
+}
+
+/// Pure delegation to the wrapped repository: the actor's shutdown path reads
+/// and updates the session through its `Logs` handle, and those operations
+/// have no placeholder side to sync.
+impl AgentSessionRepo for PlaceholderSyncingLogs
+where
+ R: AgentSessionRepo + AgentSessionLogRepo + Clone,
+ Folds: FoldedMessageRepo + Clone + Send + Sync + 'static,
+ C: Comms + Clone + Send + Sync + 'static,
+{
+ async fn create(&self, params: CreateAgentSessionParams) -> Result {
+ AgentSessionRepo::create(&self.repo, params).await
+ }
+
+ async fn get(&self, id: AgentSessionId) -> Result {
+ self.repo.get(id).await
+ }
+
+ async fn find_for_channel(
+ &self,
+ channel_id: Uuid,
+ thread_id: Option,
+ bot_id: Option,
+ ) -> Result {
+ self.repo.find_for_channel(channel_id, thread_id, bot_id).await
+ }
+
+ async fn update(&self, session: AgentSession) -> Result<()> {
+ self.repo.update(session).await
+ }
+
+ async fn set_acp_session_id(
+ &self,
+ id: AgentSessionId,
+ acp_session_id: SessionId,
+ ) -> Result<()> {
+ self.repo.set_acp_session_id(id, acp_session_id).await
+ }
+
+ async fn delete(&self, id: AgentSessionId) -> Result<()> {
+ self.repo.delete(id).await
+ }
+}
+
+/// Write a placeholder comms message for every folded message `session`'s log
+/// derives that comms has not seen yet.
+async fn create_missing_placeholders(
+ repo: &R,
+ folds: &Folds,
+ comms: &C,
+ session: AgentSessionId,
+) -> Result<()>
+where
+ R: AgentSessionRepo,
+ Folds: FoldedMessageRepo,
+ C: Comms,
+{
+ // Refold the whole session - fine for now - and create placeholders for
+ // whatever messages comms has not seen yet. The fold is the source of
+ // truth; comms only mirrors it.
+ let messages = folds.messages(session).await?;
+ if messages.is_empty() {
+ return Ok(());
+ }
+
+ let session = repo.get(session).await?;
+ let existing = comms.messages_with_placeholders(&session).await?;
+ for message in messages {
+ let id = message.id();
+ if existing.contains(&id) {
+ continue;
+ }
+ comms
+ .create_message_placeholder(&session, id, &message.author)
+ .await?;
+ }
+
+ Ok(())
}
/// Step the actor until its machine stops, then release the registry entry.
diff --git a/crates/agent_session/src/domain/service/test.rs b/crates/agent_session/src/domain/service/test.rs
new file mode 100644
index 00000000000..9765a775820
--- /dev/null
+++ b/crates/agent_session/src/domain/service/test.rs
@@ -0,0 +1,321 @@
+use super::*;
+use crate::domain::model::{Message, TurnId};
+use crate::testing::{InMemoryAgentSessionRepo, RecordingComms, test_agent_session};
+use agent_fold::domain::model::{Author, AuthorKind, FoldedMessage, MessageId, MessagePart};
+use agent_fold::domain::service::FoldedMessageService;
+use agent_fold::testing::{TURN, parse_log_as, test_session};
+use agent_runtime_protocol::domain::schema::v0::ToServerMessage;
+use macro_uuid::Uuid;
+use non_empty::NonEmpty;
+use std::collections::HashMap;
+use std::sync::{Arc, Mutex};
+
+/// A bare folded message for `turn`, authored by `author`. Only its key and
+/// author matter here - the service reads nothing else.
+fn folded(turn: u32, author: Author) -> FoldedMessage {
+ FoldedMessage {
+ id: TurnId(turn),
+ author,
+ parts: NonEmpty::new(vec![MessagePart::Text("x".to_owned())])
+ .expect("one part is not empty"),
+ stop: None,
+ }
+}
+
+/// A [`FoldedMessageRepo`] answering from a preset table, standing in for the
+/// fold.
+#[derive(Debug, Clone, Default)]
+struct StaticMessages {
+ messages: Arc>>>,
+}
+
+impl StaticMessages {
+ /// Set the session's messages to both sides of each given turn, which is
+ /// what a completed turn folds to.
+ fn set(&self, session: AgentSessionId, turns: impl IntoIterator- ) {
+ let messages = turns
+ .into_iter()
+ .flat_map(|turn| {
+ [
+ folded(turn, Author::User(None)),
+ folded(turn, Author::Agent),
+ ]
+ })
+ .collect();
+ self.messages
+ .lock()
+ .expect("message table is not poisoned")
+ .insert(session, messages);
+ }
+}
+
+impl FoldedMessageRepo for StaticMessages {
+ async fn messages(
+ &self,
+ session: AgentSessionId,
+ ) -> Result
, rootcause::Report> {
+ Ok(self
+ .messages
+ .lock()
+ .expect("message table is not poisoned")
+ .get(&session)
+ .cloned()
+ .unwrap_or_default())
+ }
+
+ async fn get_message(
+ &self,
+ _session: AgentSessionId,
+ _id: MessageId,
+ ) -> Result, rootcause::Report> {
+ Ok(None)
+ }
+
+ async fn message_ids(
+ &self,
+ session: AgentSessionId,
+ ) -> Result, rootcause::Report> {
+ Ok(FoldedMessageRepo::messages(self, session)
+ .await?
+ .iter()
+ .map(FoldedMessage::id)
+ .collect())
+ }
+}
+
+/// Both placeholder keys a completed turn produces, in fold order.
+fn both_sides(channel: Uuid, turn: u32) -> Vec<(Uuid, MessageId)> {
+ vec![
+ (
+ channel,
+ MessageId {
+ turn: TurnId(turn),
+ author: AuthorKind::User,
+ },
+ ),
+ (
+ channel,
+ MessageId {
+ turn: TurnId(turn),
+ author: AuthorKind::Agent,
+ },
+ ),
+ ]
+}
+
+struct Fixture {
+ service: AgentSessionServiceImpl,
+ repo: InMemoryAgentSessionRepo,
+ turns: StaticMessages,
+ comms: RecordingComms,
+ session: AgentSessionId,
+ channel: Uuid,
+}
+
+fn fixture() -> Fixture {
+ let repo = InMemoryAgentSessionRepo::new();
+ let turns = StaticMessages::default();
+ let comms = RecordingComms::new();
+ let session = AgentSessionId::new_from_uuid(Uuid::from_u128(1));
+ let channel = Uuid::from_u128(0xc4a2);
+ repo.insert_session(test_agent_session(session, channel));
+
+ Fixture {
+ service: AgentSessionServiceImpl::new(repo.clone(), turns.clone(), comms.clone()),
+ repo,
+ turns,
+ comms,
+ session,
+ channel,
+ }
+}
+
+/// Any protocol frame will do: the service only stores it, turn detection is
+/// the fold's answer.
+fn any_event(session: AgentSessionId) -> AgentSessionLog {
+ AgentSessionLog {
+ agent_session_id: session,
+ user_id: None,
+ content: Message::ToServer(ToServerMessage::Event {
+ event: agent_runtime_protocol::domain::schema::v0::SystemEvent::AcpReady,
+ }),
+ }
+}
+
+/// An append that derives messages comms has not seen writes one placeholder
+/// each - both sides of the turn, separately keyed.
+#[tokio::test]
+async fn appending_a_new_turn_creates_a_placeholder_per_side() {
+ let fx = fixture();
+ fx.turns.set(fx.session, [0]);
+
+ fx.service
+ .append_event(any_event(fx.session))
+ .await
+ .expect("append succeeds");
+
+ assert_eq!(fx.comms.created(), both_sides(fx.channel, 0));
+}
+
+/// The event is persisted to the log whether or not it opens a turn.
+#[tokio::test]
+async fn appending_persists_the_event() {
+ let fx = fixture();
+
+ fx.service
+ .append_event(any_event(fx.session))
+ .await
+ .expect("append succeeds");
+ fx.service
+ .append_event(any_event(fx.session))
+ .await
+ .expect("append succeeds");
+
+ let log = AgentSessionLogRepo::list_by_session(&fx.repo, fx.session)
+ .await
+ .expect("in-memory repo cannot fail");
+ assert_eq!(log.len(), 2);
+ assert_eq!(fx.comms.created(), vec![], "no messages, no placeholders");
+}
+
+/// A message that already has a placeholder is not written again; only the
+/// newly derived ones are.
+#[tokio::test]
+async fn only_unseen_messages_get_placeholders() {
+ let fx = fixture();
+ fx.turns.set(fx.session, [0]);
+ fx.service
+ .append_event(any_event(fx.session))
+ .await
+ .expect("append succeeds");
+
+ // The next event closes turn 0 and opens turn 1: the fold now derives
+ // both, but only turn 1 is missing a placeholder.
+ fx.turns.set(fx.session, [0, 1]);
+ fx.service
+ .append_event(any_event(fx.session))
+ .await
+ .expect("append succeeds");
+
+ let mut expected = both_sides(fx.channel, 0);
+ expected.extend(both_sides(fx.channel, 1));
+ assert_eq!(
+ fx.comms.created(),
+ expected,
+ "each message gets exactly one placeholder, in order"
+ );
+}
+
+/// Appends land on the appended session's channel, not anyone else's.
+#[tokio::test]
+async fn placeholders_are_scoped_to_the_session() {
+ let fx = fixture();
+ let other = AgentSessionId::new_from_uuid(Uuid::from_u128(2));
+ let other_channel = Uuid::from_u128(0xc4a3);
+ fx.repo
+ .insert_session(test_agent_session(other, other_channel));
+
+ fx.turns.set(fx.session, [0]);
+ fx.turns.set(other, [0]);
+
+ fx.service
+ .append_event(any_event(other))
+ .await
+ .expect("append succeeds");
+
+ assert_eq!(
+ fx.comms.created(),
+ both_sides(other_channel, 0),
+ "only the appended session's channel is written to"
+ );
+}
+
+/// The whole loop, on a real recording: the service appends each protocol
+/// frame, `agent_fold` refolds the log, and a comms placeholder appears for
+/// each side as the fold derives it - the user's when the prompt opens the
+/// turn, the agent's once it has content - and never twice.
+#[tokio::test]
+async fn appends_place_one_comms_placeholder_per_folded_message() {
+ let store = InMemoryAgentSessionRepo::new();
+ let channel = Uuid::from_u128(0xc4a2);
+ store.insert_session(test_agent_session(test_session(), channel));
+
+ let comms = RecordingComms::new();
+ let service = AgentSessionServiceImpl::new(
+ store.clone(),
+ FoldedMessageService::new(store.clone()),
+ comms.clone(),
+ );
+
+ let mut prompt_seen = false;
+ for entry in parse_log_as(test_session(), TURN) {
+ // The fixture attributes exactly the prompt frames to a user.
+ prompt_seen |= entry.user_id.is_some();
+
+ service.append_event(entry).await.expect("append succeeds");
+
+ let created = comms.created();
+ assert!(
+ created.len() <= 2,
+ "one turn folds to at most two messages, got {created:?}"
+ );
+ assert!(
+ created.iter().all(|(written, _)| *written == channel),
+ "placeholders land on the session's channel"
+ );
+ let unique: std::collections::HashSet<_> = created.iter().collect();
+ assert_eq!(unique.len(), created.len(), "no placeholder written twice");
+ if !prompt_seen {
+ assert!(created.is_empty(), "nothing renders before the prompt");
+ }
+ }
+ assert!(prompt_seen, "fixture contains a prompt");
+ assert_eq!(
+ comms.created(),
+ both_sides(channel, 0),
+ "the completed turn ends with both sides placed"
+ );
+}
+
+/// `channel_messages` resolves a dedicated channel to its session and folds
+/// that session's log.
+#[tokio::test]
+async fn channel_messages_folds_the_channels_session() {
+ let store = InMemoryAgentSessionRepo::new();
+ let channel = Uuid::from_u128(0xc4a2);
+ store.insert_session(test_agent_session(test_session(), channel));
+ store.extend_log(parse_log_as(test_session(), TURN));
+
+ let service = AgentSessionServiceImpl::new(
+ store.clone(),
+ FoldedMessageService::new(store.clone()),
+ RecordingComms::new(),
+ );
+
+ let folded = service
+ .channel_messages(channel)
+ .await
+ .expect("lookup succeeds")
+ .expect("the channel belongs to a session");
+
+ assert_eq!(folded.agent_session_id, test_session());
+ assert!(
+ !folded.messages.is_empty(),
+ "the recorded turn folds to messages"
+ );
+}
+
+/// A channel no session owns yields `None`, not an error.
+#[tokio::test]
+async fn channel_messages_without_a_session_is_none() {
+ let fx = fixture();
+ let unowned_channel = Uuid::from_u128(0xffff);
+
+ let folded = fx
+ .service
+ .channel_messages(unowned_channel)
+ .await
+ .expect("lookup succeeds");
+
+ assert!(folded.is_none());
+}
diff --git a/crates/agent_session/src/inbound/axum_router.rs b/crates/agent_session/src/inbound/axum_router.rs
index f23a4a85d63..4e37910dcdd 100644
--- a/crates/agent_session/src/inbound/axum_router.rs
+++ b/crates/agent_session/src/inbound/axum_router.rs
@@ -26,8 +26,15 @@ use macro_uuid::Uuid;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
+use agent_fold::domain::model::{
+ Author, FileDiff, FoldedMessage, MessagePart, Permission, PermissionOption, PermissionOutcome,
+ StopReason, ToolDetail, ToolStatus, ToolUse,
+};
+
use crate::domain::error::AgentSessionError;
-use crate::domain::model::{AgentSession, AgentSessionId, SessionStatus};
+use crate::domain::model::{
+ AgentSession, AgentSessionId, ChannelFoldedMessages, SessionStatus, composite_message_id,
+};
use crate::domain::service::AgentSessionService;
/// Shared state for the agent session router: the agent session service plus
@@ -78,12 +85,18 @@ where
.put(update_agent_session_handler::)
.delete(delete_agent_session_handler::),
)
+ .route(
+ "/channel/{channel_id}/messages",
+ get(get_agent_channel_messages_handler::),
+ )
.with_state(state)
}
/// Transport error for agent session handlers.
#[derive(Debug)]
pub enum AgentSessionApiError {
+ /// No agent session owns the addressed channel.
+ NoSessionForChannel,
/// The domain rejected the operation.
Domain(AgentSessionError),
}
@@ -97,6 +110,9 @@ impl From for AgentSessionApiError {
impl IntoResponse for AgentSessionApiError {
fn into_response(self) -> Response {
match self {
+ Self::NoSessionForChannel => {
+ (StatusCode::NOT_FOUND, "no agent session owns this channel").into_response()
+ }
Self::Domain(error) => {
tracing::error!(error = ?error, "agent session request failed");
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response()
@@ -338,3 +354,398 @@ pub async fn delete_agent_session_handler<
Ok(StatusCode::OK)
}
+
+/// Response body for a channel's folded agent-session messages.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct AgentChannelMessagesResponse {
+ /// The session whose log derived the messages.
+ pub agent_session_id: Uuid,
+ /// The session's folded messages, oldest first.
+ pub messages: Vec,
+}
+
+impl From for AgentChannelMessagesResponse {
+ fn from(folded: ChannelFoldedMessages) -> Self {
+ let session = folded.agent_session_id;
+ Self {
+ agent_session_id: session.as_uuid(),
+ messages: folded
+ .messages
+ .into_iter()
+ .map(|message| FoldedMessageDto::new(session, message))
+ .collect(),
+ }
+ }
+}
+
+/// One renderable message folded from a session's protocol log, mirroring
+/// [`FoldedMessage`].
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct FoldedMessageDto {
+ /// The composite id the placeholder comms message for this folded message
+ /// carries in its `agent_session_message_id`:
+ /// `"{agent_session_id}:{turn}:{author}"`. Readers join folded messages
+ /// onto placeholder rows by this, one to one.
+ pub agent_session_message_id: String,
+ /// The turn within the session, assigned in log order from zero.
+ pub turn: u32,
+ /// Who produced the message.
+ pub author: FoldedAuthorDto,
+ /// Ordered renderable content. Never empty.
+ pub parts: Vec,
+ /// How the turn ended, on the agent message that closed it. Absent while
+ /// the turn is in flight or when the session died without a response.
+ pub stop: Option,
+}
+
+impl FoldedMessageDto {
+ /// Map a folded message into its transport shape, stamping the composite
+ /// message id of the session it was folded from.
+ fn new(session: AgentSessionId, message: FoldedMessage) -> Self {
+ Self {
+ agent_session_message_id: composite_message_id(session, message.id()),
+ turn: message.id.0,
+ author: message.author.into(),
+ parts: message
+ .parts
+ .into_inner()
+ .into_iter()
+ .map(Into::into)
+ .collect(),
+ stop: message.stop.map(Into::into),
+ }
+ }
+}
+
+/// Who produced a folded message, mirroring [`Author`].
+///
+/// Multi-word fields in these enums carry explicit `#[serde(rename)]`s
+/// instead of `rename_all_fields = "camelCase"`: utoipa does not read
+/// `rename_all_fields`, so the explicit form keeps the generated schema and
+/// the serialized wire format in agreement.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum FoldedAuthorDto {
+ /// A person, via `session/prompt`.
+ User {
+ /// The user's macro id, absent when the prompt was unattributed.
+ #[serde(rename = "userId")]
+ user_id: Option,
+ },
+ /// The agent.
+ Agent,
+}
+
+impl From for FoldedAuthorDto {
+ fn from(author: Author) -> Self {
+ match author {
+ Author::User(user_id) => Self::User {
+ user_id: user_id.map(|id| id.to_string()),
+ },
+ Author::Agent => Self::Agent,
+ }
+ }
+}
+
+/// A unit of renderable content, mirroring [`MessagePart`].
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum FoldedMessagePartDto {
+ /// Prose from the user or the agent.
+ Text {
+ /// The prose.
+ text: String,
+ },
+ /// The agent's reasoning, which a reader may want to hide by default.
+ Thought {
+ /// The reasoning.
+ text: String,
+ },
+ /// A tool the agent invoked.
+ ToolUse {
+ /// The ACP `toolCallId`.
+ id: String,
+ /// What to show as the tool's name.
+ label: String,
+ /// Where the call got to.
+ status: ToolStatusDto,
+ /// What the tool did, as far as the log reveals.
+ detail: ToolDetailDto,
+ },
+ /// The agent asking to proceed with a tool call.
+ Permission {
+ /// The `toolCallId` permission was requested for.
+ #[serde(rename = "toolCall")]
+ tool_call: String,
+ /// The choices offered, in the order ACP listed them.
+ options: Vec,
+ /// What the user chose. Absent while the request is outstanding, or
+ /// when the session ended before anyone answered.
+ outcome: Option,
+ },
+}
+
+impl From for FoldedMessagePartDto {
+ fn from(part: MessagePart) -> Self {
+ match part {
+ MessagePart::Text(text) => Self::Text { text },
+ MessagePart::Thought(text) => Self::Thought { text },
+ MessagePart::ToolUse(ToolUse {
+ id,
+ label,
+ status,
+ detail,
+ }) => Self::ToolUse {
+ id: id.0,
+ label,
+ status: status.into(),
+ detail: detail.into(),
+ },
+ MessagePart::Permission(Permission {
+ tool_call,
+ options,
+ outcome,
+ }) => Self::Permission {
+ tool_call: tool_call.0,
+ options: options.into_iter().map(Into::into).collect(),
+ outcome: outcome.map(Into::into),
+ },
+ }
+ }
+}
+
+/// How far a tool call progressed, mirroring [`ToolStatus`].
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum ToolStatusDto {
+ /// Not started - still streaming input or awaiting permission.
+ Pending,
+ /// Currently running.
+ Running,
+ /// Finished successfully.
+ Completed,
+ /// Finished unsuccessfully.
+ Failed,
+}
+
+impl From for ToolStatusDto {
+ fn from(status: ToolStatus) -> Self {
+ match status {
+ ToolStatus::Pending => Self::Pending,
+ ToolStatus::Running => Self::Running,
+ ToolStatus::Completed => Self::Completed,
+ ToolStatus::Failed => Self::Failed,
+ }
+ }
+}
+
+/// What a tool call actually did, mirroring [`ToolDetail`].
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum ToolDetailDto {
+ /// A shell command.
+ Terminal {
+ /// The command line, when the harness reported one.
+ command: Option,
+ /// Captured output, ANSI escape sequences left in place.
+ output: Option,
+ /// Process exit code, when the harness reported one.
+ #[serde(rename = "exitCode")]
+ exit_code: Option,
+ },
+ /// One or more file modifications.
+ Edit {
+ /// The diffs ACP reported for this call.
+ diffs: Vec,
+ },
+ /// A file read.
+ Read {
+ /// Paths this call touched.
+ paths: Vec,
+ },
+ /// Anything else, including tools the fold has no special rendering for.
+ Other {
+ /// ACP's tool kind, as its wire string.
+ #[serde(rename = "acpKind")]
+ acp_kind: String,
+ /// The tool's input, when reported.
+ #[schema(value_type = Option)]
+ input: Option,
+ },
+}
+
+impl From for ToolDetailDto {
+ fn from(detail: ToolDetail) -> Self {
+ match detail {
+ ToolDetail::Terminal {
+ command,
+ output,
+ exit_code,
+ } => Self::Terminal {
+ command,
+ output: output.map(|output| output.as_str().to_owned()),
+ exit_code,
+ },
+ ToolDetail::Edit { diffs } => Self::Edit {
+ diffs: diffs.into_iter().map(Into::into).collect(),
+ },
+ ToolDetail::Read { paths } => Self::Read {
+ paths: paths
+ .into_iter()
+ .map(|path| path.to_string_lossy().into_owned())
+ .collect(),
+ },
+ ToolDetail::Other { kind, input } => Self::Other {
+ acp_kind: kind,
+ input,
+ },
+ }
+ }
+}
+
+/// A file modification a tool reported, mirroring [`FileDiff`].
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct FileDiffDto {
+ /// The file that changed.
+ pub path: String,
+ /// Prior contents, absent when the file is new.
+ pub old_text: Option,
+ /// New contents.
+ pub new_text: String,
+}
+
+impl From for FileDiffDto {
+ fn from(diff: FileDiff) -> Self {
+ Self {
+ path: diff.path.to_string_lossy().into_owned(),
+ old_text: diff.old_text,
+ new_text: diff.new_text,
+ }
+ }
+}
+
+/// One choice offered for a permission request, mirroring
+/// [`PermissionOption`].
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct PermissionOptionDto {
+ /// The id to report back when this option is chosen.
+ pub id: String,
+ /// Label to show.
+ pub name: String,
+ /// ACP's option kind, as its wire string - `allow_once`, `reject_once`,
+ /// `allow_always`, `reject_always`.
+ pub kind: String,
+}
+
+impl From for PermissionOptionDto {
+ fn from(option: PermissionOption) -> Self {
+ Self {
+ id: option.id,
+ name: option.name,
+ kind: option.kind,
+ }
+ }
+}
+
+/// How a permission request resolved, mirroring [`PermissionOutcome`].
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum PermissionOutcomeDto {
+ /// An option was chosen.
+ Selected {
+ /// The chosen option's id.
+ #[serde(rename = "optionId")]
+ option_id: String,
+ },
+ /// The request was cancelled without a choice.
+ Cancelled,
+}
+
+impl From for PermissionOutcomeDto {
+ fn from(outcome: PermissionOutcome) -> Self {
+ match outcome {
+ PermissionOutcome::Selected { option_id } => Self::Selected { option_id },
+ PermissionOutcome::Cancelled => Self::Cancelled,
+ }
+ }
+}
+
+/// Why a turn stopped, mirroring [`StopReason`].
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum StopReasonDto {
+ /// The agent finished its turn.
+ EndTurn,
+ /// The model hit its token limit.
+ MaxTokens,
+ /// The agent hit its turn-request limit.
+ MaxTurnRequests,
+ /// The agent declined.
+ Refusal,
+ /// The turn was cancelled.
+ Cancelled,
+ /// A stop reason the fold does not model.
+ Other {
+ /// The wire string.
+ reason: String,
+ },
+}
+
+impl From for StopReasonDto {
+ fn from(stop: StopReason) -> Self {
+ match stop {
+ StopReason::EndTurn => Self::EndTurn,
+ StopReason::MaxTokens => Self::MaxTokens,
+ StopReason::MaxTurnRequests => Self::MaxTurnRequests,
+ StopReason::Refusal => Self::Refusal,
+ StopReason::Cancelled => Self::Cancelled,
+ StopReason::Other(reason) => Self::Other { reason },
+ }
+ }
+}
+
+#[utoipa::path(
+ get,
+ path = "/agent-sessions/channel/{channel_id}/messages",
+ tag = "agent-sessions",
+ operation_id = "get_agent_channel_messages",
+ params(("channel_id" = Uuid, Path, description = "ID of the session's dedicated channel")),
+ responses(
+ (status = 200, body = AgentChannelMessagesResponse),
+ (status = 401, body = String),
+ (status = 403, body = String),
+ (status = 404, body = String),
+ (status = 500, body = String),
+ )
+)]
+/// The folded messages of the agent session behind a channel.
+///
+/// Placeholder comms messages in an agent channel store no body, only an
+/// `agent_session_message_id`; each message here carries the same composite id,
+/// so a reader joins the two to render the channel. `404` when no agent
+/// session owns the channel.
+#[tracing::instrument(
+ skip(state, caller),
+ fields(actor = %caller.acting_entity(), channel_id = %channel_id),
+ err(Debug)
+)]
+pub async fn get_agent_channel_messages_handler<
+ T: AgentSessionService,
+ Auth: MacroAuthorizationService,
+>(
+ State(state): State>,
+ caller: MacroAuthorizationExtractor,
+ Path(channel_id): Path,
+) -> Result, AgentSessionApiError> {
+ let folded = state
+ .service
+ .channel_messages(channel_id)
+ .await?
+ .ok_or(AgentSessionApiError::NoSessionForChannel)?;
+
+ Ok(Json(folded.into()))
+}
diff --git a/crates/agent_session/src/outbound/postgres/mod.rs b/crates/agent_session/src/outbound/postgres/mod.rs
index 46760e2efd8..939231b0ef0 100644
--- a/crates/agent_session/src/outbound/postgres/mod.rs
+++ b/crates/agent_session/src/outbound/postgres/mod.rs
@@ -1,15 +1,16 @@
//! Postgres implementation of the agent session and agent session log
-//! repositories.
+//! repositories, the fold's log source, and the comms placeholder writer.
#[cfg(test)]
mod test;
use crate::domain::error::Result;
use crate::domain::model::{
- AgentSession, AgentSessionId, AgentSessionLog, ChannelSession, CreateAgentSessionParams,
- Message, SessionStatus,
+ AgentSession, AgentSessionId, AgentSessionLog, Author, ChannelSession,
+ CreateAgentSessionParams, Message, MessageId, SessionStatus, composite_message_id,
+ parse_composite_message_id,
};
-use crate::domain::ports::{AgentSessionLogRepo, AgentSessionRepo};
+use crate::domain::ports::{AgentSessionLogRepo, AgentSessionRepo, Comms};
use agent_client_protocol::schema::v1::SessionId;
use agent_runtime_protocol::domain::schema::v0::{SystemEvent, ToRuntimeMessage, ToServerMessage};
use anyhow::Context;
@@ -18,6 +19,7 @@ use chrono::{DateTime, Utc};
use macro_user_id::user_id::MacroUserIdStr;
use macro_uuid::Uuid;
use sqlx::PgPool;
+use std::collections::HashSet;
/// Postgres implementation of [`AgentSessionRepo`] and [`AgentSessionLogRepo`].
#[derive(Debug, Clone)]
@@ -469,3 +471,103 @@ impl AgentSessionLogRepo for PgAgentSessionRepo {
.collect::>>()?)
}
}
+
+/// Folding reads the log through `agent_fold`'s own port; this adapter
+/// already speaks [`AgentSessionLogRepo`], so bridging is one line.
+impl agent_fold::domain::ports::LogRepo for PgAgentSessionRepo {
+ async fn list_by_session(
+ &self,
+ session: AgentSessionId,
+ ) -> std::result::Result, rootcause::Report> {
+ let log = AgentSessionLogRepo::list_by_session(self, session)
+ .await
+ .map_err(|error| rootcause::report!(error))?;
+ Ok(log.into())
+ }
+}
+
+impl Comms for PgAgentSessionRepo {
+ async fn messages_with_placeholders(
+ &self,
+ session: &AgentSession,
+ ) -> std::result::Result, rootcause::Report> {
+ let ids = sqlx::query_scalar!(
+ r#"
+ SELECT agent_session_message_id AS "agent_session_message_id!"
+ FROM comms_messages
+ WHERE channel_id = $1 AND agent_session_message_id IS NOT NULL
+ "#,
+ session.channel_id,
+ )
+ .fetch_all(&self.pool)
+ .await
+ .map_err(|error| rootcause::report!(error))?;
+
+ Ok(ids
+ .iter()
+ .filter_map(|id| parse_composite_message_id(session.id, id))
+ .collect())
+ }
+
+ async fn create_message_placeholder(
+ &self,
+ session: &AgentSession,
+ id: MessageId,
+ author: &Author,
+ ) -> std::result::Result<(), rootcause::Report> {
+ let sender = self.placeholder_sender(session, author).await?;
+ sqlx::query!(
+ r#"
+ INSERT INTO comms_messages (id, channel_id, sender_id, agent_session_message_id)
+ VALUES ($1, $2, $3, $4)
+ ON CONFLICT (agent_session_message_id) WHERE agent_session_message_id IS NOT NULL
+ DO NOTHING
+ "#,
+ macro_uuid::generate_uuid_v7(),
+ session.channel_id,
+ sender,
+ composite_message_id(session.id, id),
+ )
+ .execute(&self.pool)
+ .await
+ .map_err(|error| rootcause::report!(error))?;
+
+ Ok(())
+ }
+}
+
+impl PgAgentSessionRepo {
+ /// The `sender_id` a placeholder row carries.
+ ///
+ /// The agent's messages are sent by the session's bot. A user's are sent
+ /// by that user - but the fold only knows who they were when the log row
+ /// carried a `user_id`, which recorded and replayed sessions do not. When
+ /// it does not, the session's channel owner stands in: they are the only
+ /// person the session is known to belong to.
+ async fn placeholder_sender(
+ &self,
+ session: &AgentSession,
+ author: &Author,
+ ) -> std::result::Result {
+ let user_id = match author {
+ Author::Agent => return Ok(session.bot_id.into_storage_id().to_string()),
+ Author::User(Some(user_id)) => return Ok(user_id.to_string()),
+ Author::User(None) => sqlx::query_scalar!(
+ r#"
+ SELECT user_id
+ FROM comms_channel_participants
+ WHERE channel_id = $1 AND role = 'owner'
+ LIMIT 1
+ "#,
+ session.channel_id,
+ )
+ .fetch_optional(&self.pool)
+ .await
+ .map_err(|error| rootcause::report!(error))?,
+ };
+
+ // A channel always has an owner participant - `create` writes one in
+ // the same transaction - but fall back rather than fail a render.
+ Ok(user_id.unwrap_or_else(|| session.bot_id.into_storage_id().to_string()))
+ }
+}
diff --git a/crates/agent_session/src/testing.rs b/crates/agent_session/src/testing.rs
index 5c5167372f3..aeabc8b794c 100644
--- a/crates/agent_session/src/testing.rs
+++ b/crates/agent_session/src/testing.rs
@@ -6,15 +6,15 @@
use crate::domain::error::{AgentSessionError, Result};
use crate::domain::model::{
- AgentSession, AgentSessionId, AgentSessionLog, ChannelSession, CreateAgentSessionParams,
- SessionStatus,
+ AgentSession, AgentSessionId, AgentSessionLog, Author, ChannelSession,
+ CreateAgentSessionParams, MessageId, SessionStatus,
};
-use crate::domain::ports::{AgentSessionLogRepo, AgentSessionRepo};
+use crate::domain::ports::{AgentSessionLogRepo, AgentSessionRepo, Comms};
use agent_client_protocol::schema::v1::SessionId;
use agent_runtime_protocol::domain::schema::v0::ToServerMessage;
use bots::domain::models::BotId;
use macro_uuid::Uuid;
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
/// An in-memory [`AgentSessionRepo`] and [`AgentSessionLogRepo`].
@@ -207,3 +207,99 @@ impl AgentSessionLogRepo for InMemoryAgentSessionRepo {
.unwrap_or_default())
}
}
+
+/// The trivial [`agent_fold::domain::ports::LogRepo`] bridge: folding reads
+/// the log through the fold crate's own port, and this store already speaks
+/// [`AgentSessionLogRepo`], so bridging is one line - the same shape as the
+/// real Postgres adapter's impl.
+impl agent_fold::domain::ports::LogRepo for InMemoryAgentSessionRepo {
+ async fn list_by_session(
+ &self,
+ session: AgentSessionId,
+ ) -> std::result::Result, rootcause::Report> {
+ let log = AgentSessionLogRepo::list_by_session(self, session)
+ .await
+ .map_err(|error| rootcause::report!(error))?;
+ Ok(log.into())
+ }
+}
+
+/// A session fixture with the given id and channel; every other field is a
+/// plausible constant.
+#[must_use]
+pub fn test_agent_session(id: AgentSessionId, channel_id: Uuid) -> AgentSession {
+ let now = chrono::Utc::now();
+ AgentSession {
+ id,
+ channel_id,
+ thread_id: None,
+ originating_message_id: None,
+ bot_id: BotId::new_from_uuid(Uuid::from_u128(0xb07)),
+ model: "claude-sonnet-5".to_string(),
+ harness: "claude-code".to_string(),
+ repo_url: "https://github.com/example/example".to_string(),
+ acp_session_id: None,
+ status: SessionStatus::NoMessages,
+ created_at: now,
+ modified_at: now,
+ }
+}
+
+/// An in-memory [`Comms`] that records the placeholder messages written
+/// through it.
+///
+/// Behaves like the real table: a placeholder written for a channel shows up
+/// in [`Comms::messages_with_placeholders`] for every session on that
+/// channel. Cheap to clone - clones share one store.
+#[derive(Debug, Clone, Default)]
+pub struct RecordingComms {
+ /// `(channel_id, message)` pairs, in write order.
+ messages: Arc>>,
+}
+
+impl RecordingComms {
+ /// An empty store.
+ #[must_use]
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Every placeholder written so far, as `(channel_id, message)` in write
+ /// order.
+ #[must_use]
+ pub fn created(&self) -> Vec<(Uuid, MessageId)> {
+ self.messages
+ .lock()
+ .expect("in-memory comms store is not poisoned")
+ .clone()
+ }
+}
+
+impl Comms for RecordingComms {
+ async fn messages_with_placeholders(
+ &self,
+ session: &AgentSession,
+ ) -> std::result::Result, rootcause::Report> {
+ Ok(self
+ .messages
+ .lock()
+ .expect("in-memory comms store is not poisoned")
+ .iter()
+ .filter(|(channel, _)| *channel == session.channel_id)
+ .map(|(_, id)| *id)
+ .collect())
+ }
+
+ async fn create_message_placeholder(
+ &self,
+ session: &AgentSession,
+ id: MessageId,
+ _author: &Author,
+ ) -> std::result::Result<(), rootcause::Report> {
+ self.messages
+ .lock()
+ .expect("in-memory comms store is not poisoned")
+ .push((session.channel_id, id));
+ Ok(())
+ }
+}
diff --git a/crates/bots/src/inbound/axum_router/tests.rs b/crates/bots/src/inbound/axum_router/tests.rs
index 6729550e803..134f69e574e 100644
--- a/crates/bots/src/inbound/axum_router/tests.rs
+++ b/crates/bots/src/inbound/axum_router/tests.rs
@@ -560,6 +560,7 @@ fn sample_self_bot(bot_id: BotId) -> Bot {
created_at: now,
updated_at: now,
deleted_at: None,
+ has_agent: false,
}
}
diff --git a/crates/channel_bots/src/domain/service.rs b/crates/channel_bots/src/domain/service.rs
index 102480b8abc..b756712cbfe 100644
--- a/crates/channel_bots/src/domain/service.rs
+++ b/crates/channel_bots/src/domain/service.rs
@@ -63,12 +63,19 @@ struct PromptLine {
is_trigger: bool,
}
+/// Trimmed message content; `None` when the message has no stored body
+/// (agent-turn placeholders) or the body is blank.
+fn trimmed_content(content: Option<&str>) -> Option {
+ let trimmed = content?.trim();
+ (!trimmed.is_empty()).then(|| trimmed.to_string())
+}
+
/// The triggering message rendered from the event itself, used when the
/// trigger is missing from fetched context (e.g. a fetch failed).
fn trigger_line(event: &BotEvent) -> PromptLine {
PromptLine {
sender: sender_label(event.requesting_user.as_ref()),
- content: event.message.content.trim().to_string(),
+ content: trimmed_content(event.message.content.as_deref()).unwrap_or_default(),
is_trigger: true,
}
}
@@ -138,11 +145,11 @@ where
.find(|message| message.id == parent_id);
if let Some(parent) = parent
&& parent.deleted_at.is_none()
- && !parent.content.trim().is_empty()
+ && let Some(content) = trimmed_content(parent.content.as_deref())
{
lines.push(PromptLine {
sender: sender_label(&parent.sender_id),
- content: parent.content.trim().to_string(),
+ content,
is_trigger: false,
});
}
@@ -155,12 +162,12 @@ where
.unwrap_or_default();
for reply in replies {
thread_ids.insert(reply.id);
- if reply.content.trim().is_empty() {
+ let Some(content) = trimmed_content(reply.content.as_deref()) else {
continue;
- }
+ };
lines.push(PromptLine {
sender: sender_label(&reply.sender_id),
- content: reply.content.trim().to_string(),
+ content,
is_trigger: reply.id == event.message.id,
});
}
@@ -206,14 +213,15 @@ where
.iter()
.filter(|message| {
message.deleted_at.is_none()
- && !message.content.trim().is_empty()
&& !thread_ids.contains(&message.id)
&& message.thread_id != Some(parent_id)
})
- .map(|message| PromptLine {
- sender: sender_label(&message.sender_id),
- content: message.content.trim().to_string(),
- is_trigger: false,
+ .filter_map(|message| {
+ Some(PromptLine {
+ sender: sender_label(&message.sender_id),
+ content: trimmed_content(message.content.as_deref())?,
+ is_trigger: false,
+ })
})
.collect();
append_block(
@@ -226,13 +234,13 @@ where
let _ = writeln!(prompt, "{mentioner} mentioned you (@macro) in a channel.");
let mut lines: Vec = nearby
.iter()
- .filter(|message| {
- message.deleted_at.is_none() && !message.content.trim().is_empty()
- })
- .map(|message| PromptLine {
- sender: sender_label(&message.sender_id),
- content: message.content.trim().to_string(),
- is_trigger: message.id == trigger_id,
+ .filter(|message| message.deleted_at.is_none())
+ .filter_map(|message| {
+ Some(PromptLine {
+ sender: sender_label(&message.sender_id),
+ content: trimmed_content(message.content.as_deref())?,
+ is_trigger: message.id == trigger_id,
+ })
})
.collect();
if !lines.iter().any(|line| line.is_trigger) {
diff --git a/crates/channel_bots/src/domain/service/tests.rs b/crates/channel_bots/src/domain/service/tests.rs
index 71fe503c022..fc40c20be61 100644
--- a/crates/channel_bots/src/domain/service/tests.rs
+++ b/crates/channel_bots/src/domain/service/tests.rs
@@ -288,13 +288,14 @@ fn context_message(
channel_id,
thread_id: None,
sender_id: sender_id.to_string(),
- content: content.to_string(),
+ content: Some(content.to_string()),
created_at: now,
updated_at: now,
edited_at: None,
deleted_at: None,
bot_profile: None,
triggered_by: None,
+ agent_session_message_id: None,
}
}
@@ -304,13 +305,14 @@ fn thread_reply(id: Uuid, sender_id: &str, content: &str) -> ThreadReply {
id,
sender_id: sender_id.to_string(),
bot_profile: None,
- content: content.to_string(),
+ content: Some(content.to_string()),
created_at: now,
updated_at: now,
edited_at: None,
triggered_by: None,
reactions: Vec::new(),
attachments: Vec::new(),
+ agent_session_message_id: None,
}
}
@@ -329,7 +331,7 @@ fn mention_event(
channel_id,
thread_id,
sender_id: Sender::new_from_user(user_id(sender_email)),
- content: content.to_string(),
+ content: Some(content.to_string()),
created_at: Utc::now(),
updated_at: Utc::now(),
edited_at: None,
diff --git a/crates/channels/src/domain/models.rs b/crates/channels/src/domain/models.rs
index 2cd4d6f7e3b..69ba45928e8 100644
--- a/crates/channels/src/domain/models.rs
+++ b/crates/channels/src/domain/models.rs
@@ -148,8 +148,12 @@ pub struct ChannelMessage {
pub triggered_by: Option,
/// Bot profile when the sender is a bot.
pub bot_profile: Option,
- /// Message body.
- pub content: String,
+ /// Message body. `None` on agent-turn placeholder messages, whose body is
+ /// folded from the agent session log on read instead of being stored.
+ pub content: Option,
+ /// The agent session turn this placeholder renders, as the composite
+ /// `"{agent_session_id}:{turn}"`. Set only on agent-turn placeholders.
+ pub agent_session_message_id: Option,
/// When the message was created.
pub created_at: DateTime,
/// When the message was last updated.
@@ -183,8 +187,8 @@ pub struct RecentChannelMessage {
pub thread_id: Option,
/// Sender actor id.
pub sender_id: String,
- /// Message body.
- pub content: String,
+ /// Message body. `None` on agent-turn placeholder messages.
+ pub content: Option,
/// Message creation timestamp.
pub created_at: DateTime,
/// Message update timestamp.
@@ -226,8 +230,11 @@ pub struct ThreadReply {
pub triggered_by: Option,
/// Bot profile when the sender is a bot.
pub bot_profile: Option,
- /// Reply body.
- pub content: String,
+ /// Reply body. `None` on agent-turn placeholder messages.
+ pub content: Option,
+ /// The agent session turn this placeholder renders, as the composite
+ /// `"{agent_session_id}:{turn}"`. Set only on agent-turn placeholders.
+ pub agent_session_message_id: Option,
/// When the reply was created.
pub created_at: DateTime,
/// When the reply was last updated.
@@ -367,8 +374,11 @@ pub struct ChannelContextMessage {
pub triggered_by: Option,
/// Bot profile when the sender is a bot.
pub bot_profile: Option,
- /// Message content.
- pub content: String,
+ /// Message content. `None` on agent-turn placeholder messages.
+ pub content: Option,
+ /// The agent session turn this placeholder renders, as the composite
+ /// `"{agent_session_id}:{turn}"`. Set only on agent-turn placeholders.
+ pub agent_session_message_id: Option,
/// When the message was created.
pub created_at: DateTime,
/// When the message was last updated.
@@ -392,8 +402,9 @@ pub struct AttachmentChannelReference {
pub thread_id: Option,
/// Sender of the message.
pub sender_id: String,
- /// Full message content (might be used for preview/snippet).
- pub message_content: String,
+ /// Full message content (might be used for preview/snippet). `None` on
+ /// agent-turn placeholder messages.
+ pub message_content: Option,
/// When the message itself was created.
pub message_created_at: DateTime,
/// When the attachment row was created.
@@ -437,8 +448,11 @@ pub struct TopLevelMessageRow {
pub sender_id: String,
/// For an agent (bot) message, the id of the user who triggered it.
pub triggered_by: Option,
- /// Message content.
- pub content: String,
+ /// Message content. `None` on agent-turn placeholder messages.
+ pub content: Option,
+ /// The agent session turn this placeholder renders, as the composite
+ /// `"{agent_session_id}:{turn}"`. Set only on agent-turn placeholders.
+ pub agent_session_message_id: Option,
/// Created timestamp.
pub created_at: DateTime,
/// Updated timestamp.
@@ -471,8 +485,11 @@ pub struct ThreadReplyRow {
pub sender_id: String,
/// For an agent (bot) reply, the id of the user who triggered it.
pub triggered_by: Option,
- /// Reply content.
- pub content: String,
+ /// Reply content. `None` on agent-turn placeholder messages.
+ pub content: Option,
+ /// The agent session turn this placeholder renders, as the composite
+ /// `"{agent_session_id}:{turn}"`. Set only on agent-turn placeholders.
+ pub agent_session_message_id: Option,
/// Created timestamp.
pub created_at: DateTime,
/// Updated timestamp.
@@ -512,6 +529,28 @@ impl std::fmt::Display for ChannelType {
}
}
+/// Kind of channel: an ordinary channel, or an agent session's dedicated
+/// channel whose agent messages are placeholders folded from the session log.
+///
+/// Stored in the TEXT `comms_channels.kind` column - `type_name = "TEXT"`
+/// points the fieldless enum derive at the built-in string type instead of a
+/// Postgres enum.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
+#[cfg_attr(feature = "outbound", derive(sqlx::Type))]
+#[cfg_attr(
+ feature = "outbound",
+ sqlx(type_name = "TEXT", rename_all = "snake_case")
+)]
+#[serde(rename_all = "snake_case")]
+pub enum ChannelKind {
+ /// An ordinary channel.
+ #[default]
+ Normal,
+ /// An agent session's dedicated channel.
+ Agent,
+}
+
/// Request to add a user to all organization channels.
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "inbound", derive(utoipa::ToSchema))]
@@ -1036,8 +1075,8 @@ pub struct MutatedMessage {
pub sender_id: ChannelSender<'static>,
/// For an agent (bot) message, the id of the user who triggered it.
pub triggered_by: Option,
- /// Message body.
- pub content: String,
+ /// Message body. `None` on agent-turn placeholder messages.
+ pub content: Option,
/// Created timestamp.
pub created_at: DateTime,
/// Updated timestamp.
@@ -1300,6 +1339,8 @@ pub struct ChannelListItem {
pub name: Option,
/// Channel type.
pub channel_type: ChannelType,
+ /// Channel kind.
+ pub kind: ChannelKind,
/// Organization id.
pub org_id: Option,
/// Team id.
diff --git a/crates/channels/src/domain/service.rs b/crates/channels/src/domain/service.rs
index 7635e55e7c2..3672c3ffa5e 100644
--- a/crates/channels/src/domain/service.rs
+++ b/crates/channels/src/domain/service.rs
@@ -180,6 +180,7 @@ where
sender_id: r.sender_id.clone(),
triggered_by: r.triggered_by.clone(),
content: r.content.clone(),
+ agent_session_message_id: r.agent_session_message_id.clone(),
created_at: r.created_at,
updated_at: r.updated_at,
edited_at: r.edited_at,
@@ -197,6 +198,7 @@ where
sender_id: row.sender_id,
triggered_by: row.triggered_by,
content: row.content,
+ agent_session_message_id: row.agent_session_message_id,
created_at: row.created_at,
updated_at: row.updated_at,
edited_at: row.edited_at,
@@ -243,6 +245,7 @@ where
sender_id: row.sender_id,
triggered_by: row.triggered_by,
content: row.content,
+ agent_session_message_id: row.agent_session_message_id,
created_at: row.created_at,
updated_at: row.updated_at,
edited_at: row.edited_at,
diff --git a/crates/channels/src/domain/service/test.rs b/crates/channels/src/domain/service/test.rs
index 4328dbce67d..975ea8514c9 100644
--- a/crates/channels/src/domain/service/test.rs
+++ b/crates/channels/src/domain/service/test.rs
@@ -32,7 +32,8 @@ fn make_row(id: Uuid, minutes_ago: i64) -> TopLevelMessageRow {
channel_id: Uuid::nil(),
sender_id: "user_1".into(),
triggered_by: None,
- content: format!("msg {minutes_ago}"),
+ content: Some(format!("msg {minutes_ago}")),
+ agent_session_message_id: None,
created_at: now - chrono::Duration::minutes(minutes_ago),
updated_at: now - chrono::Duration::minutes(minutes_ago),
edited_at: None,
@@ -102,7 +103,8 @@ async fn returns_messages_with_thread_info() {
thread_id: parent_id,
sender_id: "user_2".into(),
triggered_by: None,
- content: "reply".into(),
+ content: Some("reply".into()),
+ agent_session_message_id: None,
created_at: Utc::now(),
updated_at: Utc::now(),
edited_at: None,
@@ -202,7 +204,8 @@ async fn attaches_bot_profiles_to_bot_authored_messages() {
thread_id: parent_id,
sender_id: seeded_bot.into_storage_id().to_string(),
triggered_by: None,
- content: "reply".into(),
+ content: Some("reply".into()),
+ agent_session_message_id: None,
created_at: Utc::now(),
updated_at: Utc::now(),
edited_at: None,
@@ -315,7 +318,7 @@ impl FakeMutationRepo {
thread_id: None,
sender_id: ChannelSender::parse_from_str(sender).unwrap().into_owned(),
triggered_by: None,
- content: "hello".to_string(),
+ content: Some("hello".to_string()),
created_at: now,
updated_at: now,
edited_at: None,
@@ -683,7 +686,7 @@ impl ChannelRepo for FakeMutationRepo {
state.message.channel_id = channel_id;
state.message.sender_id = sender_id.into_owned();
state.message.triggered_by = triggered_by_user_id;
- state.message.content = content;
+ state.message.content = Some(content);
state.message.thread_id = thread_id;
Ok(state.message.clone())
}
@@ -796,7 +799,7 @@ impl ChannelRepo for FakeMutationRepo {
) -> Result {
let mut state = self.state.lock().unwrap();
state.patched_content = Some(content.clone());
- state.message.content = content;
+ state.message.content = Some(content);
state.message.edited_at = Some(Utc::now());
Ok(state.message.clone())
}
@@ -1347,7 +1350,7 @@ async fn patch_message_content_emits_message_changed_event_to_thread_participant
};
assert_eq!(*emitted_channel_id, channel_id);
assert_eq!(message.id, message_id);
- assert_eq!(message.content, "edited");
+ assert_eq!(message.content.as_deref(), Some("edited"));
assert_eq!(nonce.as_deref(), Some("edit-nonce"));
assert_eq!(recipients.len(), 1);
assert_eq!(recipients[0].as_ref(), "macro|thread@test.com");
@@ -1546,7 +1549,7 @@ async fn patch_message_notify_as_posted_adds_notification_context() {
else {
panic!("expected MessageChanged event, got {:?}", emitted[0]);
};
- assert_eq!(message.content, "final answer");
+ assert_eq!(message.content.as_deref(), Some("final answer"));
let posted_notification = posted_notification
.as_ref()
.expect("expected posted notification context");
@@ -1992,7 +1995,8 @@ async fn thread_replies_resolve_and_hydrate() {
thread_id: parent.id,
sender_id: "macro|user-a@test.com".into(),
triggered_by: None,
- content: "reply 1".into(),
+ content: Some("reply 1".into()),
+ agent_session_message_id: None,
created_at: Utc::now(),
updated_at: Utc::now(),
edited_at: None,
@@ -2002,7 +2006,8 @@ async fn thread_replies_resolve_and_hydrate() {
thread_id: parent.id,
sender_id: "macro|user-b@test.com".into(),
triggered_by: None,
- content: "reply 2".into(),
+ content: Some("reply 2".into()),
+ agent_session_message_id: None,
created_at: Utc::now(),
updated_at: Utc::now(),
edited_at: None,
diff --git a/crates/channels/src/domain/side_effects.rs b/crates/channels/src/domain/side_effects.rs
index 03eb3bcc200..da9ad5faf4a 100644
--- a/crates/channels/src/domain/side_effects.rs
+++ b/crates/channels/src/domain/side_effects.rs
@@ -937,7 +937,7 @@ where
self.send_notification(ChannelNotificationEffect::UserMention {
mention: ChannelMentionNotification {
channel_id,
- message_content: message.content.clone(),
+ message_content: message.content.clone().unwrap_or_default(),
message_id: message.id,
has_attachments,
thread_id: message.thread_id,
@@ -969,7 +969,7 @@ where
self.send_notification(ChannelNotificationEffect::DocumentMention {
mention: ChannelMentionNotification {
channel_id,
- message_content: message.content.clone(),
+ message_content: message.content.clone().unwrap_or_default(),
message_id: message.id,
has_attachments,
thread_id: message.thread_id,
@@ -1002,7 +1002,7 @@ where
thread_id,
message_id: message.id,
sender: context.sender.clone(),
- message_content: message.content.clone(),
+ message_content: message.content.clone().unwrap_or_default(),
has_attachments,
thread_parent_sender_id: context.thread_context.parent_sender_id.clone(),
metadata: context.metadata.clone(),
@@ -1051,7 +1051,7 @@ where
recipient_user_ids,
existing_user_ids,
sender_profile_picture_url: context.sender_profile_picture_url,
- message_content: Some(message.content.clone()),
+ message_content: message.content.clone(),
metadata: context.metadata,
})
.await;
@@ -1068,7 +1068,7 @@ where
channel_id,
message_id: message.id,
sender: context.sender.clone(),
- message_content: message.content.clone(),
+ message_content: message.content.clone().unwrap_or_default(),
has_attachments,
metadata: context.metadata.clone(),
sender_profile_picture_url: context.sender_profile_picture_url.clone(),
@@ -1283,7 +1283,7 @@ fn broker_events_for_event(event: &ChannelEvent) -> Vec {
sender: message.sender_id.clone(),
triggered_by: message.triggered_by.clone(),
channel_type: metadata.channel_type,
- content: message.content.clone(),
+ content: message.content.clone().unwrap_or_default(),
mentions: mentions.clone(),
attachments: attachments
.iter()
@@ -1333,7 +1333,7 @@ fn broker_events_for_event(event: &ChannelEvent) -> Vec {
thread_id: message.thread_id,
sender: message.sender_id.clone(),
channel_type: metadata.channel_type,
- content: message.content.clone(),
+ content: message.content.clone().unwrap_or_default(),
mentioned: mention.clone(),
created_at: message.created_at,
}));
@@ -1351,7 +1351,7 @@ fn broker_events_for_event(event: &ChannelEvent) -> Vec {
message_id: message.id,
thread_id: message.thread_id,
actor: actor.clone(),
- content: message.content.clone(),
+ content: message.content.clone().unwrap_or_default(),
edited_at: message.edited_at,
updated_at: message.updated_at,
},
diff --git a/crates/channels/src/domain/side_effects/test.rs b/crates/channels/src/domain/side_effects/test.rs
index e4781698ee6..a537568cc0d 100644
--- a/crates/channels/src/domain/side_effects/test.rs
+++ b/crates/channels/src/domain/side_effects/test.rs
@@ -156,7 +156,7 @@ async fn macro_ai_bot_profile_is_builtin_without_context_lookup() {
thread_id: None,
sender_id: Sender::new_from_bot(bot_id::MACRO_AI_BOT_ID),
triggered_by: None,
- content: "hello".to_string(),
+ content: Some("hello".to_string()),
created_at: now,
updated_at: now,
edited_at: None,
@@ -192,7 +192,7 @@ async fn non_macro_bot_profile_uses_context_lookup() {
thread_id: None,
sender_id: Sender::new_from_bot(BotId::new_from_uuid(Uuid::new_v4())),
triggered_by: None,
- content: "hello".to_string(),
+ content: Some("hello".to_string()),
created_at: now,
updated_at: now,
edited_at: None,
@@ -254,7 +254,7 @@ async fn message_posted_derives_realtime_and_notification_effects() {
thread_id: None,
sender_id: Sender::new_from_user(sender.clone()),
triggered_by: None,
- content: "hello".to_string(),
+ content: Some("hello".to_string()),
created_at: now,
updated_at: now,
edited_at: None,
@@ -333,7 +333,7 @@ fn bot_message_posted_event(
thread_id,
sender_id: Sender::new_from_bot(BotId::new_from_uuid(Uuid::new_v4())),
triggered_by: None,
- content: "hello".to_string(),
+ content: Some("hello".to_string()),
created_at: now,
updated_at: now,
edited_at: None,
@@ -392,7 +392,7 @@ async fn silent_message_posted_skips_notifications_only() {
thread_id: None,
sender_id: Sender::new_from_user(sender),
triggered_by: None,
- content: "transient".to_string(),
+ content: Some("transient".to_string()),
created_at: now,
updated_at: now,
edited_at: None,
@@ -455,7 +455,7 @@ async fn mentions_only_skips_failing_invite_lookup_and_sends_mention() {
thread_id: None,
sender_id: Sender::new_from_user(sender),
triggered_by: None,
- content: "welcome".to_string(),
+ content: Some("welcome".to_string()),
created_at: now,
updated_at: now,
edited_at: None,
@@ -510,7 +510,7 @@ async fn message_changed_with_posted_notification_context_sends_notification() {
thread_id: Some(thread_id),
sender_id: Sender::new_from_bot(bot_id::MACRO_AI_BOT_ID),
triggered_by: None,
- content: "final answer".to_string(),
+ content: Some("final answer".to_string()),
created_at: now,
updated_at: now,
edited_at: Some(now),
@@ -799,7 +799,7 @@ async fn user_message_with_bot_mention_enqueues_bot_trigger() {
thread_id: None,
sender_id: Sender::new_from_user(sender),
triggered_by: None,
- content: "@macro help".to_string(),
+ content: Some("@macro help".to_string()),
created_at: now,
updated_at: now,
edited_at: None,
@@ -859,7 +859,7 @@ async fn user_message_with_uninstalled_bot_mention_does_not_enqueue_bot_trigger(
thread_id: None,
sender_id: Sender::new_from_user(user("sender@example.com")),
triggered_by: None,
- content: "@bot help".to_string(),
+ content: Some("@bot help".to_string()),
created_at: now,
updated_at: now,
edited_at: None,
@@ -939,7 +939,7 @@ async fn document_mentions_notify_participants_except_sender() {
thread_id: None,
sender_id: Sender::new_from_user(sender.clone()),
triggered_by: None,
- content: "hello".to_string(),
+ content: Some("hello".to_string()),
created_at: now,
updated_at: now,
edited_at: None,
@@ -1202,7 +1202,7 @@ fn channel_message(channel_id: Uuid, message_id: Uuid) -> MutatedMessage {
thread_id: None,
sender_id: Sender::new_from_user(user("alice@example.com")),
triggered_by: None,
- content: "updated message".to_string(),
+ content: Some("updated message".to_string()),
created_at: now,
updated_at: now,
edited_at: Some(now),
@@ -1260,7 +1260,7 @@ async fn handle_publishes_message_posted_and_attachment_created_events() {
thread_id: None,
sender_id: Sender::new_from_user(user("alice@example.com")),
triggered_by: None,
- content: "hello world".to_string(),
+ content: Some("hello world".to_string()),
created_at: now,
updated_at: now,
edited_at: None,
@@ -1554,7 +1554,7 @@ fn message_posted_with_mentions(
thread_id: None,
sender_id: sender,
triggered_by: None,
- content: "hello bots".to_string(),
+ content: Some("hello bots".to_string()),
created_at: now,
updated_at: now,
edited_at: None,
@@ -1699,7 +1699,7 @@ fn broker_events_skip_mentions_on_message_changed() {
thread_id: None,
sender_id: Sender::new_from_user(user("alice@example.com")),
triggered_by: None,
- content: "edited to mention a bot".to_string(),
+ content: Some("edited to mention a bot".to_string()),
created_at: now,
updated_at: now,
edited_at: Some(now),
@@ -1790,7 +1790,7 @@ fn mention_broker_events_map_message_posted_mentions() {
thread_id: None,
sender_id: Sender::new_from_user(user("alice@example.com")),
triggered_by: None,
- content: "@bot @doc-1".to_string(),
+ content: Some("@bot @doc-1".to_string()),
created_at: now,
updated_at: now,
edited_at: None,
diff --git a/crates/channels/src/inbound/attachment.rs b/crates/channels/src/inbound/attachment.rs
index 4d740ca4933..40bc35f9236 100644
--- a/crates/channels/src/inbound/attachment.rs
+++ b/crates/channels/src/inbound/attachment.rs
@@ -119,7 +119,7 @@ fn format_messages(messages: &[RecentChannelMessage]) -> String {
let tag = XmlTag {
name: "message",
attrs: attrs(&[("sender", &msg.sender_id), ("timestamp", ×tamp)]),
- body: &msg.content,
+ body: msg.content.as_deref().unwrap_or_default(),
};
writeln!(&mut body, "{tag}").expect("write to string");
}
diff --git a/crates/channels/src/inbound/axum_router.rs b/crates/channels/src/inbound/axum_router.rs
index dfd306d6a8e..6b01889d0b9 100644
--- a/crates/channels/src/inbound/axum_router.rs
+++ b/crates/channels/src/inbound/axum_router.rs
@@ -1922,8 +1922,13 @@ pub struct ApiChannelMessage {
sender_id: String,
/// Structured sender identity.
sender: ApiMessageSender,
- /// Message content.
- content: String,
+ /// Message content. `None` on agent-turn placeholder messages, whose body
+ /// is folded from the agent session log and joined in by the client.
+ content: Option,
+ /// The agent session turn this placeholder renders, as the composite
+ /// `"{agent_session_id}:{turn}"`. Set only on agent-turn placeholders;
+ /// join it against the folded messages of the channel's agent session.
+ agent_session_message_id: Option,
/// When the message was created.
created_at: DateTime,
/// When the message was last updated.
@@ -1952,6 +1957,7 @@ impl From for ApiChannelMessage {
),
sender_id: m.sender_id,
content: m.content,
+ agent_session_message_id: m.agent_session_message_id,
created_at: m.created_at,
updated_at: m.updated_at,
edited_at: m.edited_at,
@@ -1991,8 +1997,11 @@ pub struct ApiChannelContextMessage {
sender_id: String,
/// Structured sender identity.
sender: ApiMessageSender,
- /// Message content.
- content: String,
+ /// Message content. `None` on agent-turn placeholder messages.
+ content: Option,
+ /// The agent session turn this placeholder renders, as the composite
+ /// `"{agent_session_id}:{turn}"`. Set only on agent-turn placeholders.
+ agent_session_message_id: Option,
/// When the message was created.
created_at: DateTime,
/// When the message was last updated.
@@ -2016,6 +2025,7 @@ impl From for ApiChannelContextMessage {
),
sender_id: message.sender_id,
content: message.content,
+ agent_session_message_id: message.agent_session_message_id,
created_at: message.created_at,
updated_at: message.updated_at,
edited_at: message.edited_at,
@@ -2067,8 +2077,9 @@ pub struct ApiAttachmentChannelReference {
pub thread_id: Option,
/// Sender of the message.
pub sender_id: String,
- /// Full message content (might be used for preview/snippet).
- pub message_content: String,
+ /// Full message content (might be used for preview/snippet). `None` on
+ /// agent-turn placeholder messages.
+ pub message_content: Option,
/// When the message itself was created.
pub message_created_at: DateTime,
/// When the attachment row was created.
@@ -2150,8 +2161,11 @@ pub struct ApiThreadReply {
sender_id: String,
/// Structured sender identity.
sender: ApiMessageSender,
- /// Reply content.
- content: String,
+ /// Reply content. `None` on agent-turn placeholder messages.
+ content: Option,
+ /// The agent session turn this placeholder renders, as the composite
+ /// `"{agent_session_id}:{turn}"`. Set only on agent-turn placeholders.
+ agent_session_message_id: Option,
/// When the reply was created.
created_at: DateTime,
/// When the reply was last updated.
@@ -2175,6 +2189,7 @@ impl From for ApiThreadReply {
),
sender_id: r.sender_id,
content: r.content,
+ agent_session_message_id: r.agent_session_message_id,
created_at: r.created_at,
updated_at: r.updated_at,
edited_at: r.edited_at,
diff --git a/crates/channels/src/inbound/axum_router/test.rs b/crates/channels/src/inbound/axum_router/test.rs
index bda6a86ed8a..74b6439f4b8 100644
--- a/crates/channels/src/inbound/axum_router/test.rs
+++ b/crates/channels/src/inbound/axum_router/test.rs
@@ -367,7 +367,8 @@ impl ChannelService for MockService {
sender_id: "macro|user@example.com".to_string(),
triggered_by: None,
bot_profile: None,
- content: "message context".to_string(),
+ content: Some("message context".to_string()),
+ agent_session_message_id: None,
created_at: now,
updated_at: now,
edited_at: None,
@@ -390,7 +391,7 @@ impl ChannelService for MockService {
message_id: Uuid::new_v4(),
thread_id: None,
sender_id: "macro|user@example.com".to_string(),
- message_content: "look at this".to_string(),
+ message_content: Some("look at this".to_string()),
message_created_at: now,
attachment_created_at: now,
},
@@ -1781,7 +1782,8 @@ impl ChannelService for AroundHasItemsService {
sender_id: "macro|user@example.com".to_string(),
triggered_by: None,
bot_profile: None,
- content: "hello".to_string(),
+ content: Some("hello".to_string()),
+ agent_session_message_id: None,
created_at: now,
updated_at: now,
edited_at: None,
diff --git a/crates/channels/src/inbound/list_router.rs b/crates/channels/src/inbound/list_router.rs
index a98df78859b..836b2e259d8 100644
--- a/crates/channels/src/inbound/list_router.rs
+++ b/crates/channels/src/inbound/list_router.rs
@@ -26,8 +26,9 @@ use uuid::Uuid;
use crate::domain::{
models::{
- Activity, ChannelListItem, ChannelType as DomainChannelType, ChannelWithLatest,
- GetChannelsRequest, ParticipantRole as DomainParticipantRole, RecentChannelMessage,
+ Activity, ChannelKind as DomainChannelKind, ChannelListItem,
+ ChannelType as DomainChannelType, ChannelWithLatest, GetChannelsRequest,
+ ParticipantRole as DomainParticipantRole, RecentChannelMessage,
},
ports::ChannelListService,
};
@@ -260,6 +261,9 @@ pub struct ApiChannelWithLatest {
pub name: Option,
/// Channel type.
pub channel_type: ApiChannelListType,
+ /// Channel kind: `normal`, or `agent` for an agent session's dedicated
+ /// channel whose agent messages are folded placeholders.
+ pub kind: ApiChannelKind,
/// Organization id.
#[schema(value_type = Option)]
pub org_id: Option,
@@ -306,6 +310,7 @@ impl ApiChannelWithLatest {
id: ch.id,
name: ch.name,
channel_type: ch.channel_type,
+ kind: ch.kind,
org_id: ch.org_id,
team_id: ch.team_id,
auto_join_team: ch.auto_join_team,
@@ -341,6 +346,9 @@ pub struct ApiChannelListItem {
pub name: Option,
/// type of the channel
pub channel_type: ApiChannelListType,
+ /// kind of the channel: `normal`, or `agent` for an agent session's
+ /// dedicated channel
+ pub kind: ApiChannelKind,
/// id of the organization this channel belongs too
#[schema(value_type = Option)]
pub org_id: Option,
@@ -363,6 +371,7 @@ impl ApiChannelListItem {
id: value.id,
name: value.name,
channel_type: ApiChannelListType::new_from_domain(value.channel_type),
+ kind: ApiChannelKind::new_from_domain(value.kind),
org_id: value.org_id.and_then(|org_id| u32::try_from(org_id).ok()),
team_id: value.team_id,
auto_join_team: value.auto_join_team,
@@ -395,6 +404,25 @@ impl ApiLatestMessage {
}
}
+/// Channel kind in API responses.
+#[derive(Debug, Clone, Copy, Serialize, ToSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum ApiChannelKind {
+ /// An ordinary channel.
+ Normal,
+ /// An agent session's dedicated channel.
+ Agent,
+}
+
+impl ApiChannelKind {
+ fn new_from_domain(value: DomainChannelKind) -> Self {
+ match value {
+ DomainChannelKind::Normal => Self::Normal,
+ DomainChannelKind::Agent => Self::Agent,
+ }
+ }
+}
+
/// Channel type in API responses.
#[derive(Debug, Clone, Copy, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
@@ -429,8 +457,8 @@ pub struct ApiChannelListMessage {
pub thread_id: Option,
/// Sender user id.
pub sender_id: String,
- /// Message content.
- pub content: String,
+ /// Message content. `None` on agent-turn placeholder messages.
+ pub content: Option,
/// Creation timestamp.
pub created_at: chrono::DateTime,
/// Update timestamp.
diff --git a/crates/channels/src/inbound/list_router/test.rs b/crates/channels/src/inbound/list_router/test.rs
index d27c4a63662..e4be42582ac 100644
--- a/crates/channels/src/inbound/list_router/test.rs
+++ b/crates/channels/src/inbound/list_router/test.rs
@@ -204,6 +204,7 @@ fn list_channel(id: Uuid, updated_at_seconds: i64) -> ChannelWithLatest {
id,
name: Some(format!("channel-{id}")),
channel_type: DomainChannelType::Private,
+ kind: DomainChannelKind::default(),
org_id: None,
team_id: None,
auto_join_team: false,
diff --git a/crates/channels/src/inbound/toolset/types.rs b/crates/channels/src/inbound/toolset/types.rs
index f1baff9dd48..b08d998214b 100644
--- a/crates/channels/src/inbound/toolset/types.rs
+++ b/crates/channels/src/inbound/toolset/types.rs
@@ -125,7 +125,7 @@ impl ToolThreadReply {
let TruncatedContent {
content,
content_truncated,
- } = truncate_content(reply.content, max_chars_per_message);
+ } = truncate_content(reply.content.unwrap_or_default(), max_chars_per_message);
Self {
id: reply.id,
@@ -206,7 +206,7 @@ impl ToolChannelMessage {
let TruncatedContent {
content,
content_truncated,
- } = truncate_content(message.content, max_chars_per_message);
+ } = truncate_content(message.content.unwrap_or_default(), max_chars_per_message);
let preview_replies = message.thread.preview;
let preview_len = if include_thread_preview {
preview_replies.len() as i64
@@ -405,7 +405,8 @@ mod tests {
sender_id: "macro|reply@example.com".to_string(),
triggered_by: None,
bot_profile: None,
- content: "reply".to_string(),
+ content: Some("reply".to_string()),
+ agent_session_message_id: None,
created_at: dt(seconds),
updated_at: dt(seconds),
edited_at: None,
@@ -422,7 +423,8 @@ mod tests {
sender_id: "macro|sender@example.com".to_string(),
triggered_by: None,
bot_profile: None,
- content: "parent".to_string(),
+ content: Some("parent".to_string()),
+ agent_session_message_id: None,
created_at: dt(1),
updated_at: dt(1),
edited_at: None,
diff --git a/crates/channels/src/outbound/connection_gateway_realtime/test.rs b/crates/channels/src/outbound/connection_gateway_realtime/test.rs
index 06bc2e39cc0..6c3e9c210a6 100644
--- a/crates/channels/src/outbound/connection_gateway_realtime/test.rs
+++ b/crates/channels/src/outbound/connection_gateway_realtime/test.rs
@@ -11,7 +11,7 @@ fn message(sender_id: Sender) -> MutatedMessage {
thread_id: None,
sender_id,
triggered_by: None,
- content: "hello".to_string(),
+ content: Some("hello".to_string()),
created_at: now,
updated_at: now,
edited_at: None,
diff --git a/crates/channels/src/outbound/pg_channels_repo.rs b/crates/channels/src/outbound/pg_channels_repo.rs
index fe5ffee9e66..41099599fdb 100644
--- a/crates/channels/src/outbound/pg_channels_repo.rs
+++ b/crates/channels/src/outbound/pg_channels_repo.rs
@@ -9,15 +9,15 @@ use crate::domain::{
models::{
Activity, ActivityType, AttachmentChannelReference, AttachmentEntityReference,
AttachmentGenericReference, BotId, BotSenderProfile, ChannelAttachment,
- ChannelAttachmentType, ChannelContextMessage, ChannelInfo, ChannelListItem, ChannelMessage,
- ChannelMessageFilters, ChannelMessageKind, ChannelMetadata, ChannelParticipant,
- ChannelPreviewRow, ChannelType, ChannelWithParticipants, CountedReaction,
- CreateChannelRequest, CreateEntityMentionOptions, CreatedChannel, EntityMention,
- GetChannelsParams, GetThreadReplyRowsParams, LatestMessage, MessageAttachment,
- MessagePageDirection, MutatedAttachment, MutatedMessage, NameLookup, NewChannelAttachment,
- ParticipantRole, PatchChannelRequest, RecentChannelMessage, ResolvedChannelMessage,
- SimpleMention, ThreadData, ThreadInfo, ThreadReply, ThreadReplyRow, TopLevelMessageRow,
- UserName, fallback_user_name,
+ ChannelAttachmentType, ChannelContextMessage, ChannelInfo, ChannelKind, ChannelListItem,
+ ChannelMessage, ChannelMessageFilters, ChannelMessageKind, ChannelMetadata,
+ ChannelParticipant, ChannelPreviewRow, ChannelType, ChannelWithParticipants,
+ CountedReaction, CreateChannelRequest, CreateEntityMentionOptions, CreatedChannel,
+ EntityMention, GetChannelsParams, GetThreadReplyRowsParams, LatestMessage,
+ MessageAttachment, MessagePageDirection, MutatedAttachment, MutatedMessage, NameLookup,
+ NewChannelAttachment, ParticipantRole, PatchChannelRequest, RecentChannelMessage,
+ ResolvedChannelMessage, SimpleMention, ThreadData, ThreadInfo, ThreadReply, ThreadReplyRow,
+ TopLevelMessageRow, UserName, fallback_user_name,
},
ports::{ChannelRepo, TopLevelMessagesQueryResult},
};
@@ -74,7 +74,8 @@ struct TopLevelRow {
channel_id: Uuid,
sender_id: String,
triggered_by_user_id: Option,
- content: String,
+ content: Option,
+ agent_session_message_id: Option,
created_at: chrono::DateTime,
updated_at: chrono::DateTime,
edited_at: Option>,
@@ -97,7 +98,8 @@ struct ThreadDataRow {
thread_id: Uuid,
sender_id: String,
triggered_by_user_id: Option,
- content: String,
+ content: Option,
+ agent_session_message_id: Option,
created_at: chrono::DateTime,
updated_at: chrono::DateTime,
edited_at: Option>,
@@ -112,7 +114,8 @@ struct ThreadReplyOnlyRow {
thread_id: Uuid,
sender_id: String,
triggered_by_user_id: Option,
- content: String,
+ content: Option,
+ agent_session_message_id: Option,
created_at: chrono::DateTime,
updated_at: chrono::DateTime,
edited_at: Option>,
@@ -168,7 +171,8 @@ struct ContextMessageRow {
thread_id: Option,
sender_id: String,
triggered_by_user_id: Option,
- content: String,
+ content: Option,
+ agent_session_message_id: Option,
created_at: chrono::DateTime,
updated_at: chrono::DateTime,
edited_at: Option>,
@@ -186,6 +190,7 @@ impl From for ChannelContextMessage {
// Bot profiles are joined in the service layer.
bot_profile: None,
content: row.content,
+ agent_session_message_id: row.agent_session_message_id,
created_at: row.created_at,
updated_at: row.updated_at,
edited_at: row.edited_at,
@@ -212,7 +217,7 @@ struct MutatedMessageRow {
thread_id: Option,
sender_id: String,
triggered_by_user_id: Option,
- content: String,
+ content: Option,
created_at: chrono::DateTime,
updated_at: chrono::DateTime,
edited_at: Option>,
@@ -789,6 +794,7 @@ static CHANNEL_LIST_SELECT: &str = r#"
uc.id as "id",
uc.name as "name",
uc.channel_type as "channel_type",
+ uc.kind as "kind",
uc.org_id as "org_id",
uc.team_id as "team_id",
uc.auto_join_team as "auto_join_team",
@@ -1138,6 +1144,7 @@ fn build_channel_thread_rows_query(
m.sender_id AS sender_id,
m.triggered_by_user_id AS triggered_by_user_id,
m.content AS content,
+ m.agent_session_message_id AS agent_session_message_id,
m.created_at AS created_at,
m.updated_at AS updated_at,
m.edited_at::timestamptz AS edited_at,
@@ -1181,6 +1188,7 @@ struct ChannelListRow {
id: Uuid,
name: Option,
channel_type: ChannelType,
+ kind: ChannelKind,
org_id: Option,
team_id: Option,
auto_join_team: bool,
@@ -1198,6 +1206,7 @@ impl ChannelListRow {
id: self.id,
name: self.name,
channel_type: self.channel_type,
+ kind: self.kind,
org_id: self.org_id,
team_id: self.team_id,
auto_join_team: self.auto_join_team,
@@ -1253,6 +1262,7 @@ impl ChannelListRepo for PgChannelsRepo {
id: row.try_get("id")?,
name: row.try_get("name")?,
channel_type: row.try_get("channel_type")?,
+ kind: row.try_get("kind")?,
org_id: row.try_get("org_id")?,
team_id: row.try_get("team_id")?,
auto_join_team: row.try_get("auto_join_team")?,
@@ -1306,6 +1316,7 @@ impl ChannelListRepo for PgChannelsRepo {
m.thread_id,
m.sender_id,
m.content,
+ m.agent_session_message_id,
m.created_at,
m.updated_at,
m.deleted_at::timestamptz AS deleted_at,
@@ -1330,6 +1341,7 @@ impl ChannelListRepo for PgChannelsRepo {
m.thread_id,
m.sender_id,
m.content,
+ m.agent_session_message_id,
m.created_at,
m.updated_at,
m.deleted_at::timestamptz AS deleted_at,
@@ -1363,24 +1375,20 @@ impl ChannelListRepo for PgChannelsRepo {
updated_at: Option>,
deleted_at: Option>,
mentions: Option>| {
- match (message_id, sender_id, content, created_at, updated_at) {
- (
- Some(message_id),
- Some(sender_id),
- Some(content),
- Some(created_at),
- Some(updated_at),
- ) => Some(RecentChannelMessage {
- message_id,
- thread_id,
- sender_id,
- content,
- created_at,
- updated_at,
- deleted_at,
- mentions: mentions.unwrap_or_default(),
- }),
- (None, _, _, _, _) => None,
+ match (message_id, sender_id, created_at, updated_at) {
+ (Some(message_id), Some(sender_id), Some(created_at), Some(updated_at)) => {
+ Some(RecentChannelMessage {
+ message_id,
+ thread_id,
+ sender_id,
+ content,
+ created_at,
+ updated_at,
+ deleted_at,
+ mentions: mentions.unwrap_or_default(),
+ })
+ }
+ (None, _, _, _) => None,
_ => {
tracing::warn!("incomplete latest message row; skipping");
None
@@ -1474,6 +1482,7 @@ impl ChannelListRepo for PgChannelsRepo {
sender_id: row.try_get("sender_id")?,
triggered_by: row.try_get::, _>("triggered_by_user_id")?,
content: row.try_get("content")?,
+ agent_session_message_id: row.try_get("agent_session_message_id")?,
created_at: row.try_get("created_at")?,
updated_at: row.try_get("updated_at")?,
edited_at: row.try_get("edited_at")?,
@@ -1540,6 +1549,7 @@ impl ChannelListRepo for PgChannelsRepo {
&reply.sender_id,
),
content: reply.content.clone(),
+ agent_session_message_id: reply.agent_session_message_id.clone(),
created_at: reply.created_at,
updated_at: reply.updated_at,
edited_at: reply.edited_at,
@@ -1560,6 +1570,7 @@ impl ChannelListRepo for PgChannelsRepo {
triggered_by: parent.triggered_by.clone(),
bot_profile: bot_profile_for_sender(&bot_profiles, &parent.sender_id),
content: parent.content,
+ agent_session_message_id: parent.agent_session_message_id,
created_at: parent.created_at,
updated_at: parent.updated_at,
edited_at: parent.edited_at,
@@ -1638,7 +1649,7 @@ impl ChannelAttachmentRepo for PgChannelsRepo {
m.id AS "message_id!",
m.thread_id,
m.sender_id AS "sender_id!",
- m.content AS "content!",
+ m.content,
m.created_at AS "created_at!",
m.updated_at AS "updated_at!",
m.deleted_at::timestamptz AS "deleted_at?",
@@ -1727,6 +1738,7 @@ impl ChannelRepo for PgChannelsRepo {
m.sender_id,
m.triggered_by_user_id,
m.content,
+ m.agent_session_message_id,
m.created_at,
m.updated_at,
m.edited_at::timestamptz AS "edited_at?",
@@ -1820,6 +1832,7 @@ impl ChannelRepo for PgChannelsRepo {
m.sender_id,
m.triggered_by_user_id,
m.content,
+ m.agent_session_message_id,
m.created_at,
m.updated_at,
m.edited_at::timestamptz AS "edited_at?",
@@ -1919,6 +1932,7 @@ impl ChannelRepo for PgChannelsRepo {
sender_id: r.sender_id,
triggered_by: r.triggered_by_user_id,
content: r.content,
+ agent_session_message_id: r.agent_session_message_id,
created_at: r.created_at,
updated_at: r.updated_at,
edited_at: r.edited_at,
@@ -1948,7 +1962,8 @@ impl ChannelRepo for PgChannelsRepo {
SELECT
id AS "id!", thread_id AS "thread_id!", sender_id AS "sender_id!",
triggered_by_user_id,
- content AS "content!", created_at AS "created_at!", updated_at AS "updated_at!",
+ content, agent_session_message_id,
+ created_at AS "created_at!", updated_at AS "updated_at!",
edited_at::timestamptz AS "edited_at?",
reply_count AS "reply_count!", latest_reply_at AS "latest_reply_at?"
FROM (
@@ -1958,6 +1973,7 @@ impl ChannelRepo for PgChannelsRepo {
r.sender_id,
r.triggered_by_user_id,
r.content,
+ r.agent_session_message_id,
r.created_at,
r.updated_at,
r.edited_at,
@@ -1992,6 +2008,7 @@ impl ChannelRepo for PgChannelsRepo {
sender_id: r.sender_id,
triggered_by: r.triggered_by_user_id,
content: r.content,
+ agent_session_message_id: r.agent_session_message_id,
created_at: r.created_at,
updated_at: r.updated_at,
edited_at: r.edited_at,
@@ -2012,6 +2029,7 @@ impl ChannelRepo for PgChannelsRepo {
sender_id,
triggered_by_user_id,
content,
+ agent_session_message_id,
created_at,
updated_at,
edited_at::timestamptz AS "edited_at?"
@@ -2033,6 +2051,7 @@ impl ChannelRepo for PgChannelsRepo {
sender_id: r.sender_id,
triggered_by: r.triggered_by_user_id,
content: r.content,
+ agent_session_message_id: r.agent_session_message_id,
created_at: r.created_at,
updated_at: r.updated_at,
edited_at: r.edited_at,
@@ -2242,6 +2261,7 @@ impl ChannelRepo for PgChannelsRepo {
sender_id,
triggered_by_user_id,
content,
+ agent_session_message_id,
created_at,
updated_at,
edited_at::timestamptz AS "edited_at?",
@@ -2269,6 +2289,7 @@ impl ChannelRepo for PgChannelsRepo {
sender_id,
triggered_by_user_id,
content,
+ agent_session_message_id,
created_at,
updated_at,
edited_at::timestamptz AS "edited_at?",
@@ -2298,6 +2319,7 @@ impl ChannelRepo for PgChannelsRepo {
sender_id,
triggered_by_user_id,
content,
+ agent_session_message_id,
created_at,
updated_at,
edited_at::timestamptz AS "edited_at?",
@@ -2487,6 +2509,7 @@ impl ChannelRepo for PgChannelsRepo {
m.sender_id,
m.triggered_by_user_id,
m.content,
+ m.agent_session_message_id,
m.created_at,
m.updated_at,
m.edited_at::timestamptz AS "edited_at?",
@@ -2511,6 +2534,7 @@ impl ChannelRepo for PgChannelsRepo {
sender_id: r.sender_id,
triggered_by: r.triggered_by_user_id,
content: r.content,
+ agent_session_message_id: r.agent_session_message_id,
created_at: r.created_at,
updated_at: r.updated_at,
edited_at: r.edited_at,
@@ -2573,6 +2597,7 @@ impl ChannelRepo for PgChannelsRepo {
m.sender_id,
m.triggered_by_user_id,
m.content,
+ m.agent_session_message_id,
m.created_at,
m.updated_at,
m.edited_at::timestamptz AS "edited_at?",
@@ -2604,6 +2629,7 @@ impl ChannelRepo for PgChannelsRepo {
m.sender_id,
m.triggered_by_user_id,
m.content,
+ m.agent_session_message_id,
m.created_at,
m.updated_at,
m.edited_at::timestamptz AS "edited_at?",
@@ -2635,6 +2661,7 @@ impl ChannelRepo for PgChannelsRepo {
sender_id: r.sender_id,
triggered_by: r.triggered_by_user_id,
content: r.content,
+ agent_session_message_id: r.agent_session_message_id,
created_at: r.created_at,
updated_at: r.updated_at,
edited_at: r.edited_at,
diff --git a/crates/channels/src/outbound/pg_channels_repo/tests.rs b/crates/channels/src/outbound/pg_channels_repo/tests.rs
index 8f04890cc8f..f04423783b0 100644
--- a/crates/channels/src/outbound/pg_channels_repo/tests.rs
+++ b/crates/channels/src/outbound/pg_channels_repo/tests.rs
@@ -1541,7 +1541,7 @@ async fn top_level_scoped_to_channel(pool: Pool) -> anyhow::Result<()>
let rows = result.rows;
assert_eq!(rows.len(), 1);
- assert_eq!(rows[0].content, "other channel msg");
+ assert_eq!(rows[0].content.as_deref(), Some("other channel msg"));
Ok(())
}
@@ -1591,8 +1591,14 @@ async fn thread_data_preview_count_limits_replies(pool: Pool) -> anyho
"only 2 preview replies returned"
);
// Preview should be the 2 oldest replies, in chronological order
- assert_eq!(thread.preview_replies[0].content, "reply 1");
- assert_eq!(thread.preview_replies[1].content, "reply 2");
+ assert_eq!(
+ thread.preview_replies[0].content.as_deref(),
+ Some("reply 1")
+ );
+ assert_eq!(
+ thread.preview_replies[1].content.as_deref(),
+ Some("reply 2")
+ );
Ok(())
}
@@ -1623,7 +1629,10 @@ async fn thread_data_multiple_parents(pool: Pool) -> anyhow::Result<()
assert!(map.contains_key(&MSG2));
assert_eq!(map[&MSG1].reply_count, 4);
assert_eq!(map[&MSG2].reply_count, 1);
- assert_eq!(map[&MSG2].preview_replies[0].content, "reply to deleted");
+ assert_eq!(
+ map[&MSG2].preview_replies[0].content.as_deref(),
+ Some("reply to deleted")
+ );
Ok(())
}
@@ -1644,7 +1653,10 @@ async fn thread_replies_returns_all_active_replies_oldest_first(
ids[3],
Uuid::from_u128(0x00000000_0000_0000_0000_00000000b004)
);
- let content: Vec<&str> = replies.iter().map(|r| r.content.as_str()).collect();
+ let content: Vec<&str> = replies
+ .iter()
+ .map(|r| r.content.as_deref().unwrap_or_default())
+ .collect();
assert_eq!(content, vec!["reply 1", "reply 2", "reply 3", "reply 4"]);
Ok(())
}
@@ -1691,7 +1703,10 @@ async fn thread_replies_excludes_deleted_rows(pool: Pool) -> anyhow::R
let active_replies = repo.get_thread_replies(MSG2).await?;
assert_eq!(active_replies.len(), 1);
assert_eq!(active_replies[0].id, REPLY4);
- assert_eq!(active_replies[0].content, "reply to deleted");
+ assert_eq!(
+ active_replies[0].content.as_deref(),
+ Some("reply to deleted")
+ );
Ok(())
}
@@ -1979,7 +1994,7 @@ async fn resolve_top_level_parent_returns_self_for_top_level(
let row = row.expect("top-level message should resolve to itself");
assert_eq!(row.id, MSG1);
- assert_eq!(row.content, "first message");
+ assert_eq!(row.content.as_deref(), Some("first message"));
Ok(())
}
@@ -2607,7 +2622,7 @@ async fn attachment_references_returns_channel_reference_for_participant(
assert_eq!(channel.len(), 1);
assert_eq!(channel[0].channel_id, CH1);
assert_eq!(channel[0].message_id, MSG1);
- assert_eq!(channel[0].message_content, "first message");
+ assert_eq!(channel[0].message_content.as_deref(), Some("first message"));
Ok(())
}
diff --git a/crates/comms_db_client/src/messages/get_channel_message.rs b/crates/comms_db_client/src/messages/get_channel_message.rs
index 6dbc557c541..e0e0d824b02 100644
--- a/crates/comms_db_client/src/messages/get_channel_message.rs
+++ b/crates/comms_db_client/src/messages/get_channel_message.rs
@@ -11,7 +11,7 @@ pub struct ChannelMessageInfo {
pub message_id: Uuid,
pub thread_id: Option,
pub sender_id: String,
- pub content: String,
+ pub content: Option,
pub created_at: chrono::DateTime,
pub updated_at: chrono::DateTime