| author | |
| committer | |
| log | 17490c0a00a6cf9a542fc8fa5d85f489fa662e70 |
| tree | 3d9da94f8500074b2fd23455fa3fcb79747b74c0 |
| parent | 281b92b48810062275798dd4107364384ebaf432 |
| signature | Signed by PGP key B5690EEEBB952194 |
12 files changed, 291 insertions(+), 8 deletions(-)
apps/server/src/checkpointing/CheckpointDiffQuery.test.ts+5| ... | @@ -109,6 +109,7 @@ describe("CheckpointDiffQuery.layer", () => { | ... | @@ -109,6 +109,7 @@ describe("CheckpointDiffQuery.layer", () => { |
| 109 | }); | 109 | }); |
| 110 | }), | 110 | }), |
| 111 | getThreadRuntimeContext: () => Effect.die("unused"), | 111 | getThreadRuntimeContext: () => Effect.die("unused"), |
| 112 | getTurnStartMessage: () => Effect.die("unused"), | ||
| 112 | getThreadShellById: () => Effect.succeed(Option.none()), | 113 | getThreadShellById: () => Effect.succeed(Option.none()), |
| 113 | getThreadDetailById: () => Effect.succeed(Option.none()), | 114 | getThreadDetailById: () => Effect.succeed(Option.none()), |
| 114 | getThreadDetailSnapshot: () => Effect.succeed(Option.none()), | 115 | getThreadDetailSnapshot: () => Effect.succeed(Option.none()), |
| ... | @@ -207,6 +208,7 @@ describe("CheckpointDiffQuery.layer", () => { | ... | @@ -207,6 +208,7 @@ describe("CheckpointDiffQuery.layer", () => { |
| 207 | getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), | 208 | getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), |
| 208 | getFullThreadDiffContext: () => Effect.die("unused"), | 209 | getFullThreadDiffContext: () => Effect.die("unused"), |
| 209 | getThreadRuntimeContext: () => Effect.die("unused"), | 210 | getThreadRuntimeContext: () => Effect.die("unused"), |
| 211 | getTurnStartMessage: () => Effect.die("unused"), | ||
| 210 | getThreadShellById: () => Effect.succeed(Option.none()), | 212 | getThreadShellById: () => Effect.succeed(Option.none()), |
| 211 | getThreadDetailById: () => Effect.succeed(Option.none()), | 213 | getThreadDetailById: () => Effect.succeed(Option.none()), |
| 212 | getThreadDetailSnapshot: () => Effect.succeed(Option.none()), | 214 | getThreadDetailSnapshot: () => Effect.succeed(Option.none()), |
| ... | @@ -295,6 +297,7 @@ describe("CheckpointDiffQuery.layer", () => { | ... | @@ -295,6 +297,7 @@ describe("CheckpointDiffQuery.layer", () => { |
| 295 | getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), | 297 | getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), |
| 296 | getFullThreadDiffContext: () => Effect.die("unused"), | 298 | getFullThreadDiffContext: () => Effect.die("unused"), |
| 297 | getThreadRuntimeContext: () => Effect.die("unused"), | 299 | getThreadRuntimeContext: () => Effect.die("unused"), |
| 300 | getTurnStartMessage: () => Effect.die("unused"), | ||
| 298 | getThreadShellById: () => Effect.succeed(Option.none()), | 301 | getThreadShellById: () => Effect.succeed(Option.none()), |
| 299 | getThreadDetailById: () => Effect.succeed(Option.none()), | 302 | getThreadDetailById: () => Effect.succeed(Option.none()), |
| 300 | getThreadDetailSnapshot: () => Effect.succeed(Option.none()), | 303 | getThreadDetailSnapshot: () => Effect.succeed(Option.none()), |
| ... | @@ -368,6 +371,7 @@ describe("CheckpointDiffQuery.layer", () => { | ... | @@ -368,6 +371,7 @@ describe("CheckpointDiffQuery.layer", () => { |
| 368 | getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), | 371 | getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), |
| 369 | getFullThreadDiffContext: () => Effect.die("unused"), | 372 | getFullThreadDiffContext: () => Effect.die("unused"), |
| 370 | getThreadRuntimeContext: () => Effect.die("unused"), | 373 | getThreadRuntimeContext: () => Effect.die("unused"), |
| 374 | getTurnStartMessage: () => Effect.die("unused"), | ||
| 371 | getThreadShellById: () => Effect.succeed(Option.none()), | 375 | getThreadShellById: () => Effect.succeed(Option.none()), |
| 372 | getThreadDetailById: () => Effect.succeed(Option.none()), | 376 | getThreadDetailById: () => Effect.succeed(Option.none()), |
| 373 | getThreadDetailSnapshot: () => Effect.succeed(Option.none()), | 377 | getThreadDetailSnapshot: () => Effect.succeed(Option.none()), |
| ... | @@ -426,6 +430,7 @@ describe("CheckpointDiffQuery.layer", () => { | ... | @@ -426,6 +430,7 @@ describe("CheckpointDiffQuery.layer", () => { |
| 426 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), | 430 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), |
| 427 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), | 431 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), |
| 428 | getThreadRuntimeContext: () => Effect.die("unused"), | 432 | getThreadRuntimeContext: () => Effect.die("unused"), |
| 433 | getTurnStartMessage: () => Effect.die("unused"), | ||
| 429 | getThreadShellById: () => Effect.succeed(Option.none()), | 434 | getThreadShellById: () => Effect.succeed(Option.none()), |
| 430 | getThreadDetailById: () => Effect.succeed(Option.none()), | 435 | getThreadDetailById: () => Effect.succeed(Option.none()), |
| 431 | getThreadDetailSnapshot: () => Effect.succeed(Option.none()), | 436 | getThreadDetailSnapshot: () => Effect.succeed(Option.none()), |
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+1| ... | @@ -422,6 +422,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -422,6 +422,7 @@ describe("OrchestrationEngine", () => { |
| 422 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), | 422 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), |
| 423 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), | 423 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), |
| 424 | getThreadRuntimeContext: () => Effect.die("unused"), | 424 | getThreadRuntimeContext: () => Effect.die("unused"), |
| 425 | getTurnStartMessage: () => Effect.die("unused"), | ||
| 425 | getThreadShellById: () => Effect.succeed(Option.none()), | 426 | getThreadShellById: () => Effect.succeed(Option.none()), |
| 426 | getThreadDetailById: () => Effect.succeed(Option.none()), | 427 | getThreadDetailById: () => Effect.succeed(Option.none()), |
| 427 | getThreadDetailSnapshot: () => Effect.succeed(Option.none()), | 428 | getThreadDetailSnapshot: () => Effect.succeed(Option.none()), |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+141| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { | 1 | import { |
| 2 | type AgentSessionImportSource, | 2 | type AgentSessionImportSource, |
| 3 | ChatAttachment, | ||
| 3 | CheckpointRef, | 4 | CheckpointRef, |
| 4 | EventId, | 5 | EventId, |
| 5 | MessageId, | 6 | MessageId, |
| ... | @@ -12,6 +13,7 @@ import { assert, it } from "@effect/vitest"; | ... | @@ -12,6 +13,7 @@ import { assert, it } from "@effect/vitest"; |
| 12 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 13 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 13 | import * as Effect from "effect/Effect"; | 14 | import * as Effect from "effect/Effect"; |
| 14 | import * as Layer from "effect/Layer"; | 15 | import * as Layer from "effect/Layer"; |
| 16 | import * as Option from "effect/Option"; | ||
| 15 | import * as Schema from "effect/Schema"; | 17 | import * as Schema from "effect/Schema"; |
| 16 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 18 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 17 | 19 | ||
| ... | @@ -31,6 +33,9 @@ const asTurnId = (value: string): TurnId => TurnId.make(value); | ... | @@ -31,6 +33,9 @@ const asTurnId = (value: string): TurnId => TurnId.make(value); |
| 31 | const asMessageId = (value: string): MessageId => MessageId.make(value); | 33 | const asMessageId = (value: string): MessageId => MessageId.make(value); |
| 32 | const asEventId = (value: string): EventId => EventId.make(value); | 34 | const asEventId = (value: string): EventId => EventId.make(value); |
| 33 | const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value); | 35 | const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value); |
| 36 | const encodeChatAttachments = Schema.encodeEffect( | ||
| 37 | Schema.fromJsonString(Schema.Array(ChatAttachment)), | ||
| 38 | ); | ||
| 34 | 39 | ||
| 35 | const projectionSnapshotLayer = it.layer( | 40 | const projectionSnapshotLayer = it.layer( |
| 36 | OrchestrationProjectionSnapshotQueryLive.pipe( | 41 | OrchestrationProjectionSnapshotQueryLive.pipe( |
| ... | @@ -598,6 +603,142 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { | ... | @@ -598,6 +603,142 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 598 | }), | 603 | }), |
| 599 | ); | 604 | ); |
| 600 | 605 | ||
| 606 | it.effect("reads one turn-start message without decoding unrelated history", () => | ||
| 607 | Effect.gen(function* () { | ||
| 608 | const query = yield* ProjectionSnapshotQuery; | ||
| 609 | const sql = yield* SqlClient.SqlClient; | ||
| 610 | const threadId = ThreadId.make("thread-turn-start-read"); | ||
| 611 | const messageId = MessageId.make("message-turn-start-read"); | ||
| 612 | const createdAt = "2026-09-05T00:00:00.000Z"; | ||
| 613 | const attachments = [ | ||
| 614 | { | ||
| 615 | type: "file" as const, | ||
| 616 | id: "notes", | ||
| 617 | name: "notes.txt", | ||
| 618 | mimeType: "text/plain", | ||
| 619 | sizeBytes: 8, | ||
| 620 | }, | ||
| 621 | ]; | ||
| 622 | const attachmentsJson = yield* encodeChatAttachments(attachments); | ||
| 623 | yield* sql` | ||
| 624 | WITH RECURSIVE history(n) AS ( | ||
| 625 | VALUES (1) UNION ALL SELECT n + 1 FROM history WHERE n < 2000 | ||
| 626 | ) | ||
| 627 | INSERT INTO projection_thread_messages ( | ||
| 628 | message_id, thread_id, turn_id, role, text, attachments_json, | ||
| 629 | is_streaming, created_at, updated_at | ||
| 630 | ) | ||
| 631 | SELECT 'turn-start-history:' || n, ${threadId}, 'old-turn:' || n, 'assistant', | ||
| 632 | 'Unrelated assistant output', 'not-json', 0, ${createdAt}, ${createdAt} | ||
| 633 | FROM history | ||
| 634 | `; | ||
| 635 | yield* sql` | ||
| 636 | INSERT INTO projection_thread_messages ( | ||
| 637 | message_id, thread_id, role, text, attachments_json, is_streaming, created_at, updated_at | ||
| 638 | ) VALUES (${messageId}, ${threadId}, 'user', 'Read these notes', | ||
| 639 | ${attachmentsJson}, 0, ${createdAt}, ${createdAt}) | ||
| 640 | `; | ||
| 641 | yield* sql` | ||
| 642 | INSERT INTO projection_thread_messages ( | ||
| 643 | message_id, thread_id, role, text, attachments_json, is_streaming, created_at, updated_at | ||
| 644 | ) VALUES ('turn-start-unrelated-user', 'thread-turn-start-unrelated', 'user', 'Unrelated prompt', | ||
| 645 | 'not-json', 0, ${createdAt}, ${createdAt}) | ||
| 646 | `; | ||
| 647 | |||
| 648 | const counter = makeSqlStatementCounter(); | ||
| 649 | const context = yield* query | ||
| 650 | .getTurnStartMessage({ threadId, messageId }) | ||
| 651 | .pipe(Effect.withTracer(counter.tracer)); | ||
| 652 | assert.equal(counter.count(), 1); | ||
| 653 | assert.deepEqual( | ||
| 654 | context, | ||
| 655 | Option.some({ | ||
| 656 | message: { | ||
| 657 | id: messageId, | ||
| 658 | role: "user", | ||
| 659 | text: "Read these notes", | ||
| 660 | turnId: null, | ||
| 661 | streaming: false, | ||
| 662 | createdAt, | ||
| 663 | updatedAt: createdAt, | ||
| 664 | attachments, | ||
| 665 | }, | ||
| 666 | hasOtherUserMessages: false, | ||
| 667 | }), | ||
| 668 | ); | ||
| 669 | assert.equal( | ||
| 670 | (yield* query.getTurnStartMessage({ | ||
| 671 | threadId: ThreadId.make("thread-turn-start-unrelated"), | ||
| 672 | messageId, | ||
| 673 | }))._tag, | ||
| 674 | "None", | ||
| 675 | ); | ||
| 676 | assert.equal( | ||
| 677 | (yield* query.getTurnStartMessage({ threadId, messageId: MessageId.make("missing") }))._tag, | ||
| 678 | "None", | ||
| 679 | ); | ||
| 680 | }).pipe( | ||
| 681 | Effect.ensuring( | ||
| 682 | Effect.gen(function* () { | ||
| 683 | const sql = yield* SqlClient.SqlClient; | ||
| 684 | yield* sql` | ||
| 685 | DELETE FROM projection_thread_messages | ||
| 686 | WHERE thread_id IN ('thread-turn-start-read', 'thread-turn-start-unrelated') | ||
| 687 | `; | ||
| 688 | }).pipe(Effect.orDie), | ||
| 689 | ), | ||
| 690 | ), | ||
| 691 | ); | ||
| 692 | |||
| 693 | it.effect("keeps compaction and queued-message eligibility in the turn-start query", () => | ||
| 694 | Effect.gen(function* () { | ||
| 695 | const query = yield* ProjectionSnapshotQuery; | ||
| 696 | const sql = yield* SqlClient.SqlClient; | ||
| 697 | const threadId = ThreadId.make("thread-turn-start-eligibility"); | ||
| 698 | const messageId = MessageId.make("message-turn-start-eligibility"); | ||
| 699 | const createdAt = "2026-09-05T00:00:00.000Z"; | ||
| 700 | yield* sql` | ||
| 701 | INSERT INTO projection_thread_messages ( | ||
| 702 | message_id, thread_id, role, text, is_streaming, created_at, updated_at | ||
| 703 | ) VALUES (${messageId}, ${threadId}, 'user', 'Start a turn', 0, ${createdAt}, ${createdAt}) | ||
| 704 | `; | ||
| 705 | yield* sql` | ||
| 706 | INSERT INTO projection_thread_messages ( | ||
| 707 | message_id, thread_id, role, text, attachments_json, is_streaming, created_at, updated_at | ||
| 708 | ) VALUES ('turn-start-other-user', ${threadId}, 'user', '/compact', NULL, 0, | ||
| 709 | '2026-09-05T00:00:01.000Z', '2026-09-05T00:00:01.000Z') | ||
| 710 | `; | ||
| 711 | |||
| 712 | for (const { text, attachments, hasOtherUserMessages } of [ | ||
| 713 | { text: "/compact", attachments: null, hasOtherUserMessages: false }, | ||
| 714 | { | ||
| 715 | text: "\t\n\r /CoMpAcT\u00a0\u2028\ufeff", | ||
| 716 | attachments: "[ ]", | ||
| 717 | hasOtherUserMessages: false, | ||
| 718 | }, | ||
| 719 | { text: "/compact keep recent errors", attachments: "[]", hasOtherUserMessages: true }, | ||
| 720 | { text: "", attachments: null, hasOtherUserMessages: true }, | ||
| 721 | { text: "Queued prompt", attachments: null, hasOtherUserMessages: true }, | ||
| 722 | { | ||
| 723 | text: "/compact", | ||
| 724 | attachments: | ||
| 725 | '[{"type":"file","id":"notes","name":"notes.txt","mimeType":"text/plain","sizeBytes":8}]', | ||
| 726 | hasOtherUserMessages: true, | ||
| 727 | }, | ||
| 728 | ]) { | ||
| 729 | yield* sql` | ||
| 730 | UPDATE projection_thread_messages SET text = ${text}, attachments_json = ${attachments} | ||
| 731 | WHERE message_id = 'turn-start-other-user' | ||
| 732 | `; | ||
| 733 | const context = yield* query.getTurnStartMessage({ threadId, messageId }); | ||
| 734 | assert.equal(context._tag, "Some"); | ||
| 735 | if (context._tag === "Some") { | ||
| 736 | assert.equal(context.value.hasOtherUserMessages, hasOtherUserMessages); | ||
| 737 | } | ||
| 738 | } | ||
| 739 | }), | ||
| 740 | ); | ||
| 741 | |||
| 601 | it.effect("keeps archived threads out of the main shell snapshot", () => | 742 | it.effect("keeps archived threads out of the main shell snapshot", () => |
| 602 | Effect.gen(function* () { | 743 | Effect.gen(function* () { |
| 603 | const snapshotQuery = yield* ProjectionSnapshotQuery; | 744 | const snapshotQuery = yield* ProjectionSnapshotQuery; |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+68| ... | @@ -91,6 +91,9 @@ const THREAD_DETAIL_ACTIVITY_LIMIT = 500; | ... | @@ -91,6 +91,9 @@ const THREAD_DETAIL_ACTIVITY_LIMIT = 500; |
| 91 | // Snapshot payloads are decoded and projected in small sequential batches so | 91 | // Snapshot payloads are decoded and projected in small sequential batches so |
| 92 | // one client read does not retain the raw payloads for the full activity window. | 92 | // one client read does not retain the raw payloads for the full activity window. |
| 93 | const THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE = 25; | 93 | const THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE = 25; |
| 94 | // SQLite trim defaults to spaces. Match the whitespace removed by String.trim. | ||
| 95 | const MESSAGE_TRIM_WHITESPACE = | ||
| 96 | "\t\n\v\f\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"; | ||
| 94 | const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( | 97 | const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( |
| 95 | Struct.assign({ | 98 | Struct.assign({ |
| 96 | defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), | 99 | defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), |
| ... | @@ -105,6 +108,9 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( | ... | @@ -105,6 +108,9 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( |
| 105 | attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), | 108 | attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), |
| 106 | }), | 109 | }), |
| 107 | ); | 110 | ); |
| 111 | const ProjectionTurnStartMessageDbRowSchema = ProjectionThreadMessageDbRowSchema.mapFields( | ||
| 112 | Struct.assign({ hasOtherUserMessages: Schema.Number }), | ||
| 113 | ); | ||
| 108 | const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; | 114 | const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; |
| 109 | const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( | 115 | const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( |
| 110 | Struct.assign({ | 116 | Struct.assign({ |
| ... | @@ -180,6 +186,10 @@ const ProjectionImportedAgentSessionSourcesRowSchema = Schema.Struct({ | ... | @@ -180,6 +186,10 @@ const ProjectionImportedAgentSessionSourcesRowSchema = Schema.Struct({ |
| 180 | const ThreadIdLookupInput = Schema.Struct({ | 186 | const ThreadIdLookupInput = Schema.Struct({ |
| 181 | threadId: ThreadId, | 187 | threadId: ThreadId, |
| 182 | }); | 188 | }); |
| 189 | const TurnStartMessageLookupInput = Schema.Struct({ | ||
| 190 | threadId: ThreadId, | ||
| 191 | messageId: MessageId, | ||
| 192 | }); | ||
| 183 | const ThreadActivityKindsLookupInput = Schema.Struct({ | 193 | const ThreadActivityKindsLookupInput = Schema.Struct({ |
| 184 | threadId: ThreadId, | 194 | threadId: ThreadId, |
| 185 | activityKinds: Schema.Array(Schema.String), | 195 | activityKinds: Schema.Array(Schema.String), |
| ... | @@ -1117,6 +1127,37 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -1117,6 +1127,37 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1117 | ), | 1127 | ), |
| 1118 | }); | 1128 | }); |
| 1119 | 1129 | ||
| 1130 | const getTurnStartMessageRow = SqlSchema.findOneOption({ | ||
| 1131 | Request: TurnStartMessageLookupInput, | ||
| 1132 | Result: ProjectionTurnStartMessageDbRowSchema, | ||
| 1133 | execute: ({ threadId, messageId }) => sql` | ||
| 1134 | SELECT | ||
| 1135 | message_id AS "messageId", | ||
| 1136 | thread_id AS "threadId", | ||
| 1137 | turn_id AS "turnId", | ||
| 1138 | role, | ||
| 1139 | text, | ||
| 1140 | attachments_json AS "attachments", | ||
| 1141 | is_streaming AS "isStreaming", | ||
| 1142 | created_at AS "createdAt", | ||
| 1143 | updated_at AS "updatedAt", | ||
| 1144 | EXISTS ( | ||
| 1145 | SELECT 1 | ||
| 1146 | FROM projection_thread_messages AS other | ||
| 1147 | WHERE other.thread_id = ${threadId} | ||
| 1148 | AND other.message_id != ${messageId} | ||
| 1149 | AND other.role = 'user' | ||
| 1150 | AND ( | ||
| 1151 | LOWER(TRIM(other.text, ${MESSAGE_TRIM_WHITESPACE})) != '/compact' | ||
| 1152 | OR COALESCE(json_array_length(other.attachments_json), 0) > 0 | ||
| 1153 | ) | ||
| 1154 | ) AS "hasOtherUserMessages" | ||
| 1155 | FROM projection_thread_messages | ||
| 1156 | WHERE thread_id = ${threadId} AND message_id = ${messageId} | ||
| 1157 | LIMIT 1 | ||
| 1158 | `, | ||
| 1159 | }); | ||
| 1160 | |||
| 1120 | const listThreadMessageRowsByThread = SqlSchema.findAll({ | 1161 | const listThreadMessageRowsByThread = SqlSchema.findAll({ |
| 1121 | Request: ThreadIdLookupInput, | 1162 | Request: ThreadIdLookupInput, |
| 1122 | Result: ProjectionThreadMessageDbRowSchema, | 1163 | Result: ProjectionThreadMessageDbRowSchema, |
| ... | @@ -2887,6 +2928,32 @@ pending_approval_requests AS ( | ... | @@ -2887,6 +2928,32 @@ pending_approval_requests AS ( |
| 2887 | })); | 2928 | })); |
| 2888 | }); | 2929 | }); |
| 2889 | 2930 | ||
| 2931 | const getTurnStartMessage: ProjectionSnapshotQueryShape["getTurnStartMessage"] = Effect.fn( | ||
| 2932 | "ProjectionSnapshotQuery.getTurnStartMessage", | ||
| 2933 | )(function* (input) { | ||
| 2934 | const message = yield* getTurnStartMessageRow(input).pipe( | ||
| 2935 | Effect.mapError( | ||
| 2936 | toPersistenceSqlOrDecodeError( | ||
| 2937 | "ProjectionSnapshotQuery.getTurnStartMessage:query", | ||
| 2938 | "ProjectionSnapshotQuery.getTurnStartMessage:decodeRow", | ||
| 2939 | ), | ||
| 2940 | ), | ||
| 2941 | ); | ||
| 2942 | return Option.map(message, (row) => ({ | ||
| 2943 | message: { | ||
| 2944 | id: row.messageId, | ||
| 2945 | role: row.role, | ||
| 2946 | text: row.text, | ||
| 2947 | turnId: row.turnId, | ||
| 2948 | streaming: row.isStreaming === 1, | ||
| 2949 | createdAt: row.createdAt, | ||
| 2950 | updatedAt: row.updatedAt, | ||
| 2951 | ...(row.attachments !== null ? { attachments: row.attachments } : {}), | ||
| 2952 | }, | ||
| 2953 | hasOtherUserMessages: row.hasOtherUserMessages === 1, | ||
| 2954 | })); | ||
| 2955 | }); | ||
| 2956 | |||
| 2890 | // Contiguous turn range bounding a windowed detail read; undefined loads the | 2957 | // Contiguous turn range bounding a windowed detail read; undefined loads the |
| 2891 | // full thread. Resolved from a window request inside the snapshot | 2958 | // full thread. Resolved from a window request inside the snapshot |
| 2892 | // transaction (see getThreadDetailSnapshot). | 2959 | // transaction (see getThreadDetailSnapshot). |
| ... | @@ -3326,6 +3393,7 @@ pending_approval_requests AS ( | ... | @@ -3326,6 +3393,7 @@ pending_approval_requests AS ( |
| 3326 | getFullThreadDiffContext, | 3393 | getFullThreadDiffContext, |
| 3327 | getThreadShellById, | 3394 | getThreadShellById, |
| 3328 | getThreadRuntimeContext, | 3395 | getThreadRuntimeContext, |
| 3396 | getTurnStartMessage, | ||
| 3329 | getThreadDetailById, | 3397 | getThreadDetailById, |
| 3330 | getThreadDetailSnapshot, | 3398 | getThreadDetailSnapshot, |
| 3331 | } satisfies ProjectionSnapshotQueryShape; | 3399 | } satisfies ProjectionSnapshotQueryShape; |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+42| ... | @@ -909,6 +909,48 @@ describe("ProviderCommandReactor", () => { | ... | @@ -909,6 +909,48 @@ describe("ProviderCommandReactor", () => { |
| 909 | }), | 909 | }), |
| 910 | ); | 910 | ); |
| 911 | 911 | ||
| 912 | effectIt.effect("starts a turn and generates its title without loading old message bodies", () => | ||
| 913 | Effect.gen(function* () { | ||
| 914 | const started = yield* Deferred.make<void>(); | ||
| 915 | const titleGenerated = yield* Deferred.make<void>(); | ||
| 916 | const harness = yield* Effect.promise(() => | ||
| 917 | createHarness({ | ||
| 918 | unreadableHistory: true, | ||
| 919 | startSessionEffect: (session) => | ||
| 920 | Deferred.succeed(started, undefined).pipe(Effect.as(session)), | ||
| 921 | }), | ||
| 922 | ); | ||
| 923 | harness.generateThreadTitle.mockReturnValue( | ||
| 924 | Deferred.succeed(titleGenerated, undefined).pipe(Effect.as({ title: "Generated title" })), | ||
| 925 | ); | ||
| 926 | yield* harness.engine.dispatch({ | ||
| 927 | type: "thread.turn.start", | ||
| 928 | commandId: CommandId.make("cmd-turn-start-with-old-history"), | ||
| 929 | threadId: ThreadId.make("thread-1"), | ||
| 930 | message: { | ||
| 931 | messageId: MessageId.make("message-turn-start-with-old-history"), | ||
| 932 | role: "user", | ||
| 933 | text: "Use the current message", | ||
| 934 | attachments: [], | ||
| 935 | }, | ||
| 936 | titleSeed: "Thread", | ||
| 937 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 938 | runtimeMode: "approval-required", | ||
| 939 | createdAt: "2026-01-01T00:00:01.000Z", | ||
| 940 | }); | ||
| 941 | yield* Deferred.await(started); | ||
| 942 | yield* Deferred.await(titleGenerated); | ||
| 943 | yield* Effect.promise(() => harness.drain()); | ||
| 944 | |||
| 945 | expect(harness.sendTurn).toHaveBeenCalledWith( | ||
| 946 | expect.objectContaining({ input: "Use the current message" }), | ||
| 947 | ); | ||
| 948 | expect(harness.generateThreadTitle).toHaveBeenCalledWith( | ||
| 949 | expect.objectContaining({ message: "Use the current message" }), | ||
| 950 | ); | ||
| 951 | }), | ||
| 952 | ); | ||
| 953 | |||
| 912 | effectIt.effect("rejects /compact without conversation context", () => | 954 | effectIt.effect("rejects /compact without conversation context", () => |
| 913 | Effect.gen(function* () { | 955 | Effect.gen(function* () { |
| 914 | const harness = yield* Effect.promise(() => createHarness()); | 956 | const harness = yield* Effect.promise(() => createHarness()); |
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+9-8| ... | @@ -1188,12 +1188,15 @@ const make = Effect.gen(function* () { | ... | @@ -1188,12 +1188,15 @@ const make = Effect.gen(function* () { |
| 1188 | return; | 1188 | return; |
| 1189 | } | 1189 | } |
| 1190 | 1190 | ||
| 1191 | const thread = yield* resolveThreadDetail(event.payload.threadId); | 1191 | const thread = yield* resolveThreadShell(event.payload.threadId); |
| 1192 | if (!thread) { | 1192 | if (!thread) { |
| 1193 | return; | 1193 | return; |
| 1194 | } | 1194 | } |
| 1195 | const message = thread.messages.find((entry) => entry.id === event.payload.messageId); | 1195 | const turnStart = yield* projectionSnapshotQuery.getTurnStartMessage({ |
| 1196 | if (!message || message.role !== "user") { | 1196 | threadId: thread.id, |
| 1197 | messageId: event.payload.messageId, | ||
| 1198 | }); | ||
| 1199 | if (Option.isNone(turnStart) || turnStart.value.message.role !== "user") { | ||
| 1197 | yield* appendProviderFailureActivity({ | 1200 | yield* appendProviderFailureActivity({ |
| 1198 | threadId: event.payload.threadId, | 1201 | threadId: event.payload.threadId, |
| 1199 | kind: "provider.turn.start.failed", | 1202 | kind: "provider.turn.start.failed", |
| ... | @@ -1205,6 +1208,7 @@ const make = Effect.gen(function* () { | ... | @@ -1205,6 +1208,7 @@ const make = Effect.gen(function* () { |
| 1205 | }); | 1208 | }); |
| 1206 | return; | 1209 | return; |
| 1207 | } | 1210 | } |
| 1211 | const { message, hasOtherUserMessages } = turnStart.value; | ||
| 1208 | const appendTurnStartFailure = (summary: string, detail: string) => | 1212 | const appendTurnStartFailure = (summary: string, detail: string) => |
| 1209 | appendProviderFailureActivity({ | 1213 | appendProviderFailureActivity({ |
| 1210 | threadId: event.payload.threadId, | 1214 | threadId: event.payload.threadId, |
| ... | @@ -1297,10 +1301,7 @@ const make = Effect.gen(function* () { | ... | @@ -1297,10 +1301,7 @@ const make = Effect.gen(function* () { |
| 1297 | yield* ensureThreadWorktree(thread); | 1301 | yield* ensureThreadWorktree(thread); |
| 1298 | 1302 | ||
| 1299 | const isCompactCommand = isCompactCommandMessage(message); | 1303 | const isCompactCommand = isCompactCommandMessage(message); |
| 1300 | const nonCompactUserMessageCount = thread.messages.filter( | 1304 | if (!hasOtherUserMessages && !isCompactCommand) { |
| 1301 | (entry) => entry.role === "user" && !isCompactCommandMessage(entry), | ||
| 1302 | ).length; | ||
| 1303 | if (nonCompactUserMessageCount === 1 && !isCompactCommand) { | ||
| 1304 | const project = yield* resolveProject(thread.projectId); | 1305 | const project = yield* resolveProject(thread.projectId); |
| 1305 | const generationCwd = | 1306 | const generationCwd = |
| 1306 | resolveThreadWorkspaceCwd({ | 1307 | resolveThreadWorkspaceCwd({ |
| ... | @@ -1371,7 +1372,7 @@ const make = Effect.gen(function* () { | ... | @@ -1371,7 +1372,7 @@ const make = Effect.gen(function* () { |
| 1371 | ), | 1372 | ), |
| 1372 | ); | 1373 | ); |
| 1373 | if (isCompactCommand) { | 1374 | if (isCompactCommand) { |
| 1374 | if (nonCompactUserMessageCount === 0) { | 1375 | if (!hasOtherUserMessages) { |
| 1375 | return yield* appendTurnStartFailure( | 1376 | return yield* appendTurnStartFailure( |
| 1376 | "Context compaction failed", | 1377 | "Context compaction failed", |
| 1377 | "Context compaction requires an existing conversation.", | 1378 | "Context compaction requires an existing conversation.", |
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+17| ... | @@ -10,7 +10,9 @@ import type { | ... | @@ -10,7 +10,9 @@ import type { |
| 10 | AgentSessionImportSource, | 10 | AgentSessionImportSource, |
| 11 | ApprovalRequestId, | 11 | ApprovalRequestId, |
| 12 | CheckpointRef, | 12 | CheckpointRef, |
| 13 | MessageId, | ||
| 13 | OrchestrationCheckpointSummary, | 14 | OrchestrationCheckpointSummary, |
| 15 | OrchestrationMessage, | ||
| 14 | OrchestrationProject, | 16 | OrchestrationProject, |
| 15 | OrchestrationProjectShell, | 17 | OrchestrationProjectShell, |
| 16 | OrchestrationReadModel, | 18 | OrchestrationReadModel, |
| ... | @@ -211,6 +213,21 @@ export interface ProjectionSnapshotQueryShape { | ... | @@ -211,6 +213,21 @@ export interface ProjectionSnapshotQueryShape { |
| 211 | ProjectionRepositoryError | 213 | ProjectionRepositoryError |
| 212 | >; | 214 | >; |
| 213 | 215 | ||
| 216 | /** | ||
| 217 | * Read one requested message and whether another non-compaction user message exists. | ||
| 218 | * Newer queued messages count too, preserving first-turn title eligibility. | ||
| 219 | */ | ||
| 220 | readonly getTurnStartMessage: (input: { | ||
| 221 | readonly threadId: ThreadId; | ||
| 222 | readonly messageId: MessageId; | ||
| 223 | }) => Effect.Effect< | ||
| 224 | Option.Option<{ | ||
| 225 | readonly message: OrchestrationMessage; | ||
| 226 | readonly hasOtherUserMessages: boolean; | ||
| 227 | }>, | ||
| 228 | ProjectionRepositoryError | ||
| 229 | >; | ||
| 230 | |||
| 214 | /** | 231 | /** |
| 215 | * Read a single active thread detail snapshot by id. | 232 | * Read a single active thread detail snapshot by id. |
| 216 | */ | 233 | */ |
apps/server/src/project/AgentSessionScanner.test.ts+1| ... | @@ -58,6 +58,7 @@ const makeProjectionSnapshotQueryLayer = (importedWorkspaceRoots: ReadonlyArray< | ... | @@ -58,6 +58,7 @@ const makeProjectionSnapshotQueryLayer = (importedWorkspaceRoots: ReadonlyArray< |
| 58 | getFullThreadDiffContext: () => Effect.die("unused"), | 58 | getFullThreadDiffContext: () => Effect.die("unused"), |
| 59 | getThreadShellById: () => Effect.die("unused"), | 59 | getThreadShellById: () => Effect.die("unused"), |
| 60 | getThreadRuntimeContext: () => Effect.die("unused"), | 60 | getThreadRuntimeContext: () => Effect.die("unused"), |
| 61 | getTurnStartMessage: () => Effect.die("unused"), | ||
| 61 | getThreadDetailById: () => Effect.die("unused"), | 62 | getThreadDetailById: () => Effect.die("unused"), |
| 62 | getThreadDetailSnapshot: () => Effect.die("unused"), | 63 | getThreadDetailSnapshot: () => Effect.die("unused"), |
| 63 | searchThreads: () => Effect.die("unused"), | 64 | searchThreads: () => Effect.die("unused"), |
apps/server/src/project/ProjectSetupScriptRunner.test.ts+1| ... | @@ -46,6 +46,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => | ... | @@ -46,6 +46,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => |
| 46 | getThreadCheckpointContext: () => Effect.die("unused"), | 46 | getThreadCheckpointContext: () => Effect.die("unused"), |
| 47 | getFullThreadDiffContext: () => Effect.die("unused"), | 47 | getFullThreadDiffContext: () => Effect.die("unused"), |
| 48 | getThreadRuntimeContext: () => Effect.die("unused"), | 48 | getThreadRuntimeContext: () => Effect.die("unused"), |
| 49 | getTurnStartMessage: () => Effect.die("unused"), | ||
| 49 | getThreadShellById: () => Effect.die("unused"), | 50 | getThreadShellById: () => Effect.die("unused"), |
| 50 | getThreadDetailById: () => Effect.die("unused"), | 51 | getThreadDetailById: () => Effect.die("unused"), |
| 51 | getThreadDetailSnapshot: () => Effect.die("unused"), | 52 | getThreadDetailSnapshot: () => Effect.die("unused"), |
apps/server/src/provider/Layers/ProviderService.test.ts+1| ... | @@ -4330,6 +4330,7 @@ describe("agent browser access", () => { | ... | @@ -4330,6 +4330,7 @@ describe("agent browser access", () => { |
| 4330 | Layer.provide(runtimeRepositoryLayer), | 4330 | Layer.provide(runtimeRepositoryLayer), |
| 4331 | ); | 4331 | ); |
| 4332 | const projectionLayer = Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { | 4332 | const projectionLayer = Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { |
| 4333 | getTurnStartMessage: () => Effect.die("unused"), | ||
| 4333 | getImportedAgentSessionSources: () => Effect.die("unused"), | 4334 | getImportedAgentSessionSources: () => Effect.die("unused"), |
| 4334 | getUserInputActivity: () => Effect.die("unused"), | 4335 | getUserInputActivity: () => Effect.die("unused"), |
| 4335 | getCommandReadModel: () => Effect.die("unused"), | 4336 | getCommandReadModel: () => Effect.die("unused"), |
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1| ... | @@ -221,6 +221,7 @@ describe("ProviderSessionReaper", () => { | ... | @@ -221,6 +221,7 @@ describe("ProviderSessionReaper", () => { |
| 221 | getThreadCheckpointContext: () => Effect.die("unused"), | 221 | getThreadCheckpointContext: () => Effect.die("unused"), |
| 222 | getFullThreadDiffContext: () => Effect.die("unused"), | 222 | getFullThreadDiffContext: () => Effect.die("unused"), |
| 223 | getThreadRuntimeContext: () => Effect.die("unused"), | 223 | getThreadRuntimeContext: () => Effect.die("unused"), |
| 224 | getTurnStartMessage: () => Effect.die("unused"), | ||
| 224 | getThreadShellById: (threadId) => | 225 | getThreadShellById: (threadId) => |
| 225 | Effect.succeed( | 226 | Effect.succeed( |
| 226 | input.readModel.threads.find((thread) => thread.id === threadId) | 227 | input.readModel.threads.find((thread) => thread.id === threadId) |
apps/server/src/serverRuntimeStartup.test.ts+4| ... | @@ -171,6 +171,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa | ... | @@ -171,6 +171,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa |
| 171 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), | 171 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), |
| 172 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), | 172 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), |
| 173 | getThreadRuntimeContext: () => Effect.die("unused"), | 173 | getThreadRuntimeContext: () => Effect.die("unused"), |
| 174 | getTurnStartMessage: () => Effect.die("unused"), | ||
| 174 | getThreadShellById: () => Effect.die("unused"), | 175 | getThreadShellById: () => Effect.die("unused"), |
| 175 | getThreadDetailById: () => Effect.die("unused"), | 176 | getThreadDetailById: () => Effect.die("unused"), |
| 176 | getThreadDetailSnapshot: () => Effect.die("unused"), | 177 | getThreadDetailSnapshot: () => Effect.die("unused"), |
| ... | @@ -257,6 +258,7 @@ it.effect.each([ | ... | @@ -257,6 +258,7 @@ it.effect.each([ |
| 257 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), | 258 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), |
| 258 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), | 259 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), |
| 259 | getThreadRuntimeContext: () => Effect.die("unused"), | 260 | getThreadRuntimeContext: () => Effect.die("unused"), |
| 261 | getTurnStartMessage: () => Effect.die("unused"), | ||
| 260 | getThreadShellById: () => Effect.die("unused"), | 262 | getThreadShellById: () => Effect.die("unused"), |
| 261 | getThreadDetailById: () => Effect.die("unused"), | 263 | getThreadDetailById: () => Effect.die("unused"), |
| 262 | getThreadDetailSnapshot: () => Effect.die("unused"), | 264 | getThreadDetailSnapshot: () => Effect.die("unused"), |
| ... | @@ -325,6 +327,7 @@ it.effect( | ... | @@ -325,6 +327,7 @@ it.effect( |
| 325 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), | 327 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), |
| 326 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), | 328 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), |
| 327 | getThreadRuntimeContext: () => Effect.die("unused"), | 329 | getThreadRuntimeContext: () => Effect.die("unused"), |
| 330 | getTurnStartMessage: () => Effect.die("unused"), | ||
| 328 | getThreadShellById: () => Effect.die("unused"), | 331 | getThreadShellById: () => Effect.die("unused"), |
| 329 | getThreadDetailById: () => Effect.die("unused"), | 332 | getThreadDetailById: () => Effect.die("unused"), |
| 330 | getThreadDetailSnapshot: () => Effect.die("unused"), | 333 | getThreadDetailSnapshot: () => Effect.die("unused"), |
| ... | @@ -386,6 +389,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa | ... | @@ -386,6 +389,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa |
| 386 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), | 389 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), |
| 387 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), | 390 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), |
| 388 | getThreadRuntimeContext: () => Effect.die("unused"), | 391 | getThreadRuntimeContext: () => Effect.die("unused"), |
| 392 | getTurnStartMessage: () => Effect.die("unused"), | ||
| 389 | getThreadShellById: () => Effect.die("unused"), | 393 | getThreadShellById: () => Effect.die("unused"), |
| 390 | getThreadDetailById: () => Effect.die("unused"), | 394 | getThreadDetailById: () => Effect.die("unused"), |
| 391 | getThreadDetailSnapshot: () => Effect.die("unused"), | 395 | getThreadDetailSnapshot: () => Effect.die("unused"), |