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-be57ee32f0493054735b4c6253ab52ef9a2569133053b69b97e69e7c5e1a32a4.json b/.sqlx/query-be57ee32f0493054735b4c6253ab52ef9a2569133053b69b97e69e7c5e1a32a4.json new file mode 100644 index 00000000000..572d984a066 --- /dev/null +++ b/.sqlx/query-be57ee32f0493054735b4c6253ab52ef9a2569133053b69b97e69e7c5e1a32a4.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE agent_session\n SET status = $2,\n status_event_name = $3,\n modified_at = now()\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "be57ee32f0493054735b4c6253ab52ef9a2569133053b69b97e69e7c5e1a32a4" +} 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 368bcbb9297..f9d80a294cc 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", @@ -187,6 +187,7 @@ dependencies = [ name = "agent_harness_service" version = "0.1.0" dependencies = [ + "agent_fold", "agent_harness", "agent_session", "agent_trigger", @@ -240,17 +241,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", @@ -4572,6 +4577,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 ( +
+
+ + Thought +
+
+ {props.text} +
+
+ ); +} + +// 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 0fc1a3cc773..435e4f6dc32 100644 --- a/crates/agent_harness/Cargo.toml +++ b/crates/agent_harness/Cargo.toml @@ -35,6 +35,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 e269efbaa81..a7be86c555d 100644 --- a/crates/agent_harness/src/domain/service/test.rs +++ b/crates/agent_harness/src/domain/service/test.rs @@ -5,11 +5,12 @@ use agent_client_protocol::schema::v1::{ ClientRequest, ContentBlock, InitializeResponse, NewSessionResponse, }; +use agent_fold::domain::service::FoldedMessageService; use agent_session::PROTOCOL_VERSION; use agent_session::domain::model::AgentSessionId; use agent_session::domain::ports::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; @@ -42,7 +43,11 @@ fn open_command() -> OpenSession { fn harness() -> ( AgentHarnessService< - AgentSessionServiceImpl, + AgentSessionServiceImpl< + InMemoryAgentSessionRepo, + FoldedMessageService, + RecordingComms, + >, MockContainerManager, AnnouncerMock, >, @@ -54,7 +59,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 e50ef07ad24..8f5e2fa8bc4 100644 --- a/crates/agent_session/src/domain/error.rs +++ b/crates/agent_session/src/domain/error.rs @@ -20,4 +20,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 58af2759899..b2f014cd523 100644 --- a/crates/agent_session/src/domain/ports.rs +++ b/crates/agent_session/src/domain/ports.rs @@ -5,6 +5,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: @@ -74,3 +75,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 204cc57decc..f6b074bef6f 100644 --- a/crates/agent_session/src/domain/service.rs +++ b/crates/agent_session/src/domain/service.rs @@ -3,18 +3,37 @@ //! 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_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. @@ -59,26 +78,62 @@ 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()), } } fn register_transport(&self, id: AgentSessionId, connector: Connector) -> Result<()> where - R: AgentSessionLogRepo + Clone, + 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); @@ -90,15 +145,24 @@ impl AgentSessionServiceImpl { } } - let actor = SessionActor::new(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, connector, logs, command_rx); tokio::spawn(run_session(actor, Arc::downgrade(&self.active))); Ok(()) } } -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, @@ -162,6 +226,121 @@ where .await .map_err(|_| AgentSessionError::Disconnected(id))? } + + #[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 + } +} + +/// 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 cafa6264239..5510e22d991 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_runtime_protocol::domain::schema::v0::{SystemEvent, ToRuntimeMessage, ToServerMessage}; use anyhow::Context; use bots::domain::models::BotId; @@ -17,6 +18,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)] @@ -442,3 +444,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 879099f1b3a..428396fa3e7 100644 --- a/crates/agent_session/src/testing.rs +++ b/crates/agent_session/src/testing.rs @@ -6,14 +6,14 @@ 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_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`]. @@ -189,3 +189,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, pub deleted_at: Option>, diff --git a/crates/comms_db_client/src/model/mod.rs b/crates/comms_db_client/src/model/mod.rs index 61c0e5222ab..b5f68799920 100644 --- a/crates/comms_db_client/src/model/mod.rs +++ b/crates/comms_db_client/src/model/mod.rs @@ -22,8 +22,8 @@ pub struct Message { /// id of the user who sent the message #[schema(value_type = String)] pub sender_id: MacroUserIdStr<'static>, - /// string content of the message - pub content: String, + /// string content of the message; `None` on agent-turn placeholder messages + pub content: Option, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, /// the time that the message was edited diff --git a/crates/graphql_soup/src/objects.rs b/crates/graphql_soup/src/objects.rs index 3810b1022dc..df38097c190 100644 --- a/crates/graphql_soup/src/objects.rs +++ b/crates/graphql_soup/src/objects.rs @@ -1156,9 +1156,9 @@ impl GraphqlSoupChannelMessagePreview { &self.message.sender_id } - /// The content. - async fn content(&self) -> &str { - &self.message.content + /// The content. `None` on agent-turn placeholder messages. + async fn content(&self) -> Option<&str> { + self.message.content.as_deref() } /// The created timestamp in RFC 3339 format. @@ -1373,9 +1373,9 @@ where None } - /// The message content. - async fn content(&self) -> &str { - &self.0.content + /// The message content. `None` on agent-turn placeholder messages. + async fn content(&self) -> Option<&str> { + self.0.content.as_deref() } /// Common channel-message metadata. diff --git a/crates/macro_db_client/migrations/20260804222432_comms_messages_agent_session_turn.down.sql b/crates/macro_db_client/migrations/20260804222432_comms_messages_agent_session_turn.down.sql new file mode 100644 index 00000000000..2b6b691bac6 --- /dev/null +++ b/crates/macro_db_client/migrations/20260804222432_comms_messages_agent_session_turn.down.sql @@ -0,0 +1,10 @@ +-- Turn placeholders have NULL content, which the NOT NULL restore would +-- reject — clear them out (they are meaningless without the column anyway). +DELETE FROM comms_messages WHERE content IS NULL; + +DROP INDEX comms_messages_agent_session_turn_unique; + +ALTER TABLE comms_messages + DROP CONSTRAINT comms_messages_content_or_turn_check, + DROP COLUMN agent_session_turn_id, + ALTER COLUMN content SET NOT NULL; diff --git a/crates/macro_db_client/migrations/20260804222432_comms_messages_agent_session_turn.up.sql b/crates/macro_db_client/migrations/20260804222432_comms_messages_agent_session_turn.up.sql new file mode 100644 index 00000000000..2f99efd4c8b --- /dev/null +++ b/crates/macro_db_client/migrations/20260804222432_comms_messages_agent_session_turn.up.sql @@ -0,0 +1,20 @@ +-- Agent turns render in channels as ordinary comms messages whose content is +-- not stored: the row is a placeholder carrying a reference to an agent +-- session turn, and the body is folded from agent_session_log on read (see +-- agent_session::domain). agent_session_turn_id is an ephemeral composite id +-- ("{agent_session_id}:{offset}") — turns have no table, so this is TEXT with +-- no FK; a dangling id after session deletion is a harmless lookup miss. +ALTER TABLE comms_messages + ALTER COLUMN content DROP NOT NULL, + ADD COLUMN agent_session_turn_id TEXT; + +-- A message stores its content, points at a turn, or both - never neither. +ALTER TABLE comms_messages + ADD CONSTRAINT comms_messages_content_or_turn_check + CHECK (num_nonnulls(content, agent_session_turn_id) >= 1); + +-- One placeholder per turn: appends refold the session and diff against +-- existing placeholders, so this backstops that diff against double-writes. +CREATE UNIQUE INDEX comms_messages_agent_session_turn_unique + ON comms_messages (agent_session_turn_id) + WHERE agent_session_turn_id IS NOT NULL; diff --git a/crates/macro_db_client/migrations/20260806202542_comms_messages_agent_session_message_id.down.sql b/crates/macro_db_client/migrations/20260806202542_comms_messages_agent_session_message_id.down.sql new file mode 100644 index 00000000000..661250b8d1c --- /dev/null +++ b/crates/macro_db_client/migrations/20260806202542_comms_messages_agent_session_message_id.down.sql @@ -0,0 +1,14 @@ +-- Placeholders are derived from agent_session_log, so the per-message rows +-- are dropped rather than collapsed back onto one row per turn: the old +-- shape rebuilds itself from the log. +DELETE FROM comms_messages WHERE agent_session_message_id IS NOT NULL; + +ALTER TABLE comms_messages + RENAME CONSTRAINT comms_messages_content_or_message_check + TO comms_messages_content_or_turn_check; + +ALTER INDEX comms_messages_agent_session_message_unique + RENAME TO comms_messages_agent_session_turn_unique; + +ALTER TABLE comms_messages + RENAME COLUMN agent_session_message_id TO agent_session_turn_id; diff --git a/crates/macro_db_client/migrations/20260806202542_comms_messages_agent_session_message_id.up.sql b/crates/macro_db_client/migrations/20260806202542_comms_messages_agent_session_message_id.up.sql new file mode 100644 index 00000000000..135003ae776 --- /dev/null +++ b/crates/macro_db_client/migrations/20260806202542_comms_messages_agent_session_message_id.up.sql @@ -0,0 +1,27 @@ +-- Placeholder comms rows were keyed per agent-session *turn* +-- ("{agent_session_id}:{turn}"), which collapsed a turn's prompt and its +-- reply onto a single row. A turn's two folded messages have different +-- authors, so one row could only ever carry one sender: the user's prompt had +-- no representation of its own unless it happened to also exist as an +-- ordinary comms message typed into the channel. Sessions whose prompts +-- arrive over ACP rather than through comms - recorded, replayed, or resumed +-- - rendered with the user's side missing entirely. +-- +-- The key becomes the folded message id, "{agent_session_id}:{turn}:{author}" +-- where author is 'user' or 'agent', so every folded message gets its own row +-- and its own sender. +-- +-- Placeholders carry no content: they are derived from agent_session_log by +-- the fold on every read. Dropping them loses nothing - the service's +-- sync_placeholders rebuilds them from the log. +DELETE FROM comms_messages WHERE agent_session_turn_id IS NOT NULL; + +ALTER TABLE comms_messages + RENAME COLUMN agent_session_turn_id TO agent_session_message_id; + +ALTER INDEX comms_messages_agent_session_turn_unique + RENAME TO comms_messages_agent_session_message_unique; + +ALTER TABLE comms_messages + RENAME CONSTRAINT comms_messages_content_or_turn_check + TO comms_messages_content_or_message_check; diff --git a/crates/models_soup/src/comms.rs b/crates/models_soup/src/comms.rs index eeacaf66e8a..1008b01805a 100644 --- a/crates/models_soup/src/comms.rs +++ b/crates/models_soup/src/comms.rs @@ -82,8 +82,8 @@ pub struct ChannelMessage { pub thread_id: Option, /// Sender 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. @@ -135,8 +135,8 @@ pub struct SoupChannelThread { pub sender_id: String, /// Structured sender identity. pub sender: SoupMessageSender, - /// 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. @@ -203,8 +203,8 @@ pub struct SoupThreadReply { pub sender_id: String, /// Structured sender identity. pub sender: SoupMessageSender, - /// Reply content. - pub content: String, + /// Reply content. `None` on agent-turn placeholder messages. + pub content: Option, /// Creation timestamp. pub created_at: chrono::DateTime, /// Update timestamp. diff --git a/crates/soup/src/domain/service/tests.rs b/crates/soup/src/domain/service/tests.rs index 3d605858bb7..0d91bf7517c 100644 --- a/crates/soup/src/domain/service/tests.rs +++ b/crates/soup/src/domain/service/tests.rs @@ -565,7 +565,7 @@ fn channel_thread_message( channel_id, sender_id: "macro|test@example.com".to_string(), bot_profile: None, - content: "thread parent".to_string(), + content: Some("thread parent".to_string()), created_at: DateTime::default(), updated_at, edited_at: None, @@ -578,17 +578,19 @@ fn channel_thread_message( id: reply_id, sender_id: "macro|other@example.com".to_string(), bot_profile: None, - content: "thread reply".to_string(), + content: Some("thread reply".to_string()), created_at: DateTime::default() + Days::new(1), updated_at: DateTime::default() + Days::new(1), edited_at: None, triggered_by: None, reactions: Vec::new(), attachments: Vec::new(), + agent_session_message_id: None, }], }, reactions: Vec::new(), attachments: Vec::new(), + agent_session_message_id: None, } } diff --git a/services/agent_harness_service/Cargo.toml b/services/agent_harness_service/Cargo.toml index 58abc77e2a9..e841dfebb16 100644 --- a/services/agent_harness_service/Cargo.toml +++ b/services/agent_harness_service/Cargo.toml @@ -9,6 +9,7 @@ name = "agent_harness_service" path = "src/main.rs" [dependencies] +agent_fold = { path = "../../crates/agent_fold" } agent_harness = { path = "../../crates/agent_harness" } agent_session = { path = "../../crates/agent_session" } agent_trigger = { path = "../../crates/agent_trigger" } diff --git a/services/agent_harness_service/src/main.rs b/services/agent_harness_service/src/main.rs index 17e8f69cb15..af5c0d480d8 100644 --- a/services/agent_harness_service/src/main.rs +++ b/services/agent_harness_service/src/main.rs @@ -9,6 +9,7 @@ mod config; use std::sync::Arc; +use agent_fold::domain::service::FoldedMessageService; use agent_harness::domain::model::SessionDefaults; use agent_harness::domain::service::AgentHarnessService; use agent_harness::inbound::kafka::{HarnessCommand, agent_trigger_to_harness_command}; @@ -84,9 +85,16 @@ async fn main() -> anyhow::Result<()> { .await .context("failed to connect to macrodb")?; - // Sessions: persistence and live actors. + // Sessions: persistence and live actors. The same repo answers all three + // ports, as in the `document_storage_service` root - a session's actor + // writes its log through the fold and comms so the frames it records show + // up as placeholder messages in the session's channel. let session_repo = PgAgentSessionRepo::new(pool.clone()); - let sessions = AgentSessionServiceImpl::new(session_repo); + let sessions = AgentSessionServiceImpl::new( + session_repo.clone(), + FoldedMessageService::new(session_repo.clone()), + session_repo, + ); // Containers: Daytona sandboxes. let containers = DaytonaContainerManager::new(DaytonaSettings { diff --git a/services/document_storage_service/Cargo.toml b/services/document_storage_service/Cargo.toml index 067acf5e2eb..8727bbfead5 100644 --- a/services/document_storage_service/Cargo.toml +++ b/services/document_storage_service/Cargo.toml @@ -27,6 +27,7 @@ local_auth = ["macro_authorization/local_auth"] location_check = [] [dependencies] +agent_fold = { path = "../../crates/agent_fold" } agent_session = { path = "../../crates/agent_session" } ai_tools = { path = "../../crates/ai_tools" } ai_usage = { path = "../../crates/ai_usage" } diff --git a/services/document_storage_service/src/api/context.rs b/services/document_storage_service/src/api/context.rs index 589bd2ef642..e328942f47d 100644 --- a/services/document_storage_service/src/api/context.rs +++ b/services/document_storage_service/src/api/context.rs @@ -1,3 +1,4 @@ +use agent_fold::domain::service::FoldedMessageService; use agent_session::domain::service::AgentSessionServiceImpl; use agent_session::inbound::axum_router::AgentSessionRouterState; use agent_session::outbound::postgres::PgAgentSessionRepo; @@ -449,9 +450,16 @@ pub(crate) type DssWebhookState = MacroWebhookRouterState; /// Type alias for the agent session router state: the domain service backed -/// by the Postgres repo, which serves both the session and log ports. -pub(crate) type DssAgentSessionState = - AgentSessionRouterState, AuthorizationService>; +/// by the Postgres repo, which serves the session, log, and comms ports, and +/// answers turn queries by folding the log on read. +pub(crate) type DssAgentSessionState = AgentSessionRouterState< + AgentSessionServiceImpl< + PgAgentSessionRepo, + FoldedMessageService, + PgAgentSessionRepo, + >, + AuthorizationService, +>; #[derive(Clone, FromRef)] pub(crate) struct ApiContext { diff --git a/services/document_storage_service/src/api/swagger.rs b/services/document_storage_service/src/api/swagger.rs index 69381f3ea3c..ca9021a3692 100644 --- a/services/document_storage_service/src/api/swagger.rs +++ b/services/document_storage_service/src/api/swagger.rs @@ -217,6 +217,7 @@ use utoipa::OpenApi; agent_session::inbound::axum_router::get_agent_session_handler, agent_session::inbound::axum_router::update_agent_session_handler, agent_session::inbound::axum_router::delete_agent_session_handler, + agent_session::inbound::axum_router::get_agent_channel_messages_handler, // channel list (comms hex) channels::inbound::list_router::get_channels_handler, @@ -468,6 +469,7 @@ use utoipa::OpenApi; channels::inbound::list_router::ApiChannelListMessage, channels::inbound::list_router::ApiChannelListParticipant, channels::inbound::list_router::ApiChannelListType, + channels::inbound::list_router::ApiChannelKind, channels::inbound::list_router::ApiParticipantListRole, // Channels @@ -495,6 +497,16 @@ use utoipa::OpenApi; agent_session::inbound::axum_router::UpdateAgentSessionRequest, agent_session::inbound::axum_router::AgentSessionResponse, agent_session::inbound::axum_router::SessionStatusDto, + agent_session::inbound::axum_router::AgentChannelMessagesResponse, + agent_session::inbound::axum_router::FoldedMessageDto, + agent_session::inbound::axum_router::FoldedAuthorDto, + agent_session::inbound::axum_router::FoldedMessagePartDto, + agent_session::inbound::axum_router::ToolStatusDto, + agent_session::inbound::axum_router::ToolDetailDto, + agent_session::inbound::axum_router::FileDiffDto, + agent_session::inbound::axum_router::PermissionOptionDto, + agent_session::inbound::axum_router::PermissionOutcomeDto, + agent_session::inbound::axum_router::StopReasonDto, channels::domain::models::GetOrCreateAction, channels::domain::models::TypingAction, channels::domain::models::ReactionAction, diff --git a/services/document_storage_service/src/main.rs b/services/document_storage_service/src/main.rs index 3258f7ea303..c1e1d5b0869 100644 --- a/services/document_storage_service/src/main.rs +++ b/services/document_storage_service/src/main.rs @@ -12,6 +12,7 @@ use crate::{ }, service::s3::S3, }; +use agent_fold::domain::service::FoldedMessageService; use agent_session::domain::service::AgentSessionServiceImpl; use agent_session::inbound::axum_router::AgentSessionRouterState; use agent_session::outbound::postgres::PgAgentSessionRepo; @@ -300,8 +301,13 @@ async fn main() -> anyhow::Result<()> { ); let authorization_state = MacroAuthorizationState::new(Arc::new(authorization_service)); + let agent_session_repo = PgAgentSessionRepo::new(db.clone()); let agent_session_state = AgentSessionRouterState::new( - AgentSessionServiceImpl::new(PgAgentSessionRepo::new(db.clone())), + AgentSessionServiceImpl::new( + agent_session_repo.clone(), + FoldedMessageService::new(agent_session_repo.clone()), + agent_session_repo, + ), authorization_state.clone(), ); diff --git a/services/search_processing_service/src/process/channel.rs b/services/search_processing_service/src/process/channel.rs index e30eb4d3f9c..c94101c5ab6 100644 --- a/services/search_processing_service/src/process/channel.rs +++ b/services/search_processing_service/src/process/channel.rs @@ -32,12 +32,16 @@ pub async fn process_channel_message_update( return Ok(()); } - let raw_content = &channel_message_info.channel_message.content; + let raw_content = channel_message_info + .channel_message + .content + .as_deref() + .unwrap_or_default(); let transformed_content = match ParsedXmlText::parse(raw_content) { Ok(parsed) => PlainTextFormatter::format_xml_text(parsed).0, Err(e) => { tracing::error!(error = ?e, %channel_id, %message_id, "failed to parse channel message content, indexing raw content"); - raw_content.clone() + raw_content.to_string() } };