Unified · Split
| @@ -109,6 +109,7 @@ describe("CheckpointDiffQuery.layer", () => { · 108 unmodified lines | ||
| 109 | 109 | }); |
| 110 | 110 | }), |
| 111 | 111 | getThreadRuntimeContext: () => Effect.die("unused"), |
| 112 | + getTurnStartMessage: () => Effect.die("unused"), | |
| 112 | 113 | getThreadShellById: () => Effect.succeed(Option.none()), |
| 113 | 114 | getThreadDetailById: () => Effect.succeed(Option.none()), |
| 114 | 115 | getThreadDetailSnapshot: () => Effect.succeed(Option.none()), |
| @@ -207,6 +208,7 @@ describe("CheckpointDiffQuery.layer", () => { · 92 unmodified lines | ||
| 207 | 208 | getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), |
| 208 | 209 | getFullThreadDiffContext: () => Effect.die("unused"), |
| 209 | 210 | getThreadRuntimeContext: () => Effect.die("unused"), |
| 211 | + getTurnStartMessage: () => Effect.die("unused"), | |
| 210 | 212 | getThreadShellById: () => Effect.succeed(Option.none()), |
| 211 | 213 | getThreadDetailById: () => Effect.succeed(Option.none()), |
| 212 | 214 | getThreadDetailSnapshot: () => Effect.succeed(Option.none()), |
| @@ -295,6 +297,7 @@ describe("CheckpointDiffQuery.layer", () => { · 82 unmodified lines | ||
| 295 | 297 | getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), |
| 296 | 298 | getFullThreadDiffContext: () => Effect.die("unused"), |
| 297 | 299 | getThreadRuntimeContext: () => Effect.die("unused"), |
| 300 | + getTurnStartMessage: () => Effect.die("unused"), | |
| 298 | 301 | getThreadShellById: () => Effect.succeed(Option.none()), |
| 299 | 302 | getThreadDetailById: () => Effect.succeed(Option.none()), |
| 300 | 303 | getThreadDetailSnapshot: () => Effect.succeed(Option.none()), |
| @@ -368,6 +371,7 @@ describe("CheckpointDiffQuery.layer", () => { · 67 unmodified lines | ||
| 368 | 371 | getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), |
| 369 | 372 | getFullThreadDiffContext: () => Effect.die("unused"), |
| 370 | 373 | getThreadRuntimeContext: () => Effect.die("unused"), |
| 374 | + getTurnStartMessage: () => Effect.die("unused"), | |
| 371 | 375 | getThreadShellById: () => Effect.succeed(Option.none()), |
| 372 | 376 | getThreadDetailById: () => Effect.succeed(Option.none()), |
| 373 | 377 | getThreadDetailSnapshot: () => Effect.succeed(Option.none()), |
| @@ -426,6 +430,7 @@ describe("CheckpointDiffQuery.layer", () => { · 52 unmodified lines | ||
| 426 | 430 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), |
| 427 | 431 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), |
| 428 | 432 | getThreadRuntimeContext: () => Effect.die("unused"), |
| 433 | + getTurnStartMessage: () => Effect.die("unused"), | |
| 429 | 434 | getThreadShellById: () => Effect.succeed(Option.none()), |
| 430 | 435 | getThreadDetailById: () => Effect.succeed(Option.none()), |
| 431 | 436 | getThreadDetailSnapshot: () => Effect.succeed(Option.none()), |
| @@ -422,6 +422,7 @@ describe("OrchestrationEngine", () => { · 421 unmodified lines | ||
| 422 | 422 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), |
| 423 | 423 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), |
| 424 | 424 | getThreadRuntimeContext: () => Effect.die("unused"), |
| 425 | + getTurnStartMessage: () => Effect.die("unused"), | |
| 425 | 426 | getThreadShellById: () => Effect.succeed(Option.none()), |
| 426 | 427 | getThreadDetailById: () => Effect.succeed(Option.none()), |
| 427 | 428 | getThreadDetailSnapshot: () => Effect.succeed(Option.none()), |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { |
| 2 | 2 | type AgentSessionImportSource, |
| 3 | + ChatAttachment, | |
| 3 | 4 | CheckpointRef, |
| 4 | 5 | EventId, |
| 5 | 6 | MessageId, |
| @@ -12,6 +13,7 @@ import { assert, it } from "@effect/vitest"; · 6 unmodified lines | ||
| 12 | 13 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 13 | 14 | import * as Effect from "effect/Effect"; |
| 14 | 15 | import * as Layer from "effect/Layer"; |
| 16 | +import * as Option from "effect/Option"; | |
| 15 | 17 | import * as Schema from "effect/Schema"; |
| 16 | 18 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 17 | 19 | |
| @@ -31,6 +33,9 @@ const asTurnId = (value: string): TurnId => TurnId.make(value); · 13 unmodified lines | ||
| 31 | 33 | const asMessageId = (value: string): MessageId => MessageId.make(value); |
| 32 | 34 | const asEventId = (value: string): EventId => EventId.make(value); |
| 33 | 35 | const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value); |
| 36 | +const encodeChatAttachments = Schema.encodeEffect( | |
| 37 | + Schema.fromJsonString(Schema.Array(ChatAttachment)), | |
| 38 | +); | |
| 34 | 39 | |
| 35 | 40 | const projectionSnapshotLayer = it.layer( |
| 36 | 41 | OrchestrationProjectionSnapshotQueryLive.pipe( |
| @@ -598,6 +603,142 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 561 unmodified lines | ||
| 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 | 742 | it.effect("keeps archived threads out of the main shell snapshot", () => |
| 602 | 743 | Effect.gen(function* () { |
| 603 | 744 | const snapshotQuery = yield* ProjectionSnapshotQuery; |
| @@ -91,6 +91,9 @@ const THREAD_DETAIL_ACTIVITY_LIMIT = 500; · 90 unmodified lines | ||
| 91 | 91 | // Snapshot payloads are decoded and projected in small sequential batches so |
| 92 | 92 | // one client read does not retain the raw payloads for the full activity window. |
| 93 | 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 | 97 | const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( |
| 95 | 98 | Struct.assign({ |
| 96 | 99 | defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), |
| @@ -105,6 +108,9 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( · 8 unmodified lines | ||
| 105 | 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 | 114 | const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; |
| 109 | 115 | const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( |
| 110 | 116 | Struct.assign({ |
| @@ -180,6 +186,10 @@ const ProjectionImportedAgentSessionSourcesRowSchema = Schema.Struct({ · 69 unmodified lines | ||
| 180 | 186 | const ThreadIdLookupInput = Schema.Struct({ |
| 181 | 187 | threadId: ThreadId, |
| 182 | 188 | }); |
| 189 | +const TurnStartMessageLookupInput = Schema.Struct({ | |
| 190 | + threadId: ThreadId, | |
| 191 | + messageId: MessageId, | |
| 192 | +}); | |
| 183 | 193 | const ThreadActivityKindsLookupInput = Schema.Struct({ |
| 184 | 194 | threadId: ThreadId, |
| 185 | 195 | activityKinds: Schema.Array(Schema.String), |
| @@ -1117,6 +1127,37 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 931 unmodified lines | ||
| 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 | 1161 | const listThreadMessageRowsByThread = SqlSchema.findAll({ |
| 1121 | 1162 | Request: ThreadIdLookupInput, |
| 1122 | 1163 | Result: ProjectionThreadMessageDbRowSchema, |
| @@ -2887,6 +2928,32 @@ pending_approval_requests AS ( · 1764 unmodified lines | ||
| 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 | 2957 | // Contiguous turn range bounding a windowed detail read; undefined loads the |
| 2891 | 2958 | // full thread. Resolved from a window request inside the snapshot |
| 2892 | 2959 | // transaction (see getThreadDetailSnapshot). |
| @@ -3326,6 +3393,7 @@ pending_approval_requests AS ( · 433 unmodified lines | ||
| 3326 | 3393 | getFullThreadDiffContext, |
| 3327 | 3394 | getThreadShellById, |
| 3328 | 3395 | getThreadRuntimeContext, |
| 3396 | + getTurnStartMessage, | |
| 3329 | 3397 | getThreadDetailById, |
| 3330 | 3398 | getThreadDetailSnapshot, |
| 3331 | 3399 | } satisfies ProjectionSnapshotQueryShape; |
| @@ -909,6 +909,48 @@ describe("ProviderCommandReactor", () => { · 908 unmodified lines | ||
| 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 | 954 | effectIt.effect("rejects /compact without conversation context", () => |
| 913 | 955 | Effect.gen(function* () { |
| 914 | 956 | const harness = yield* Effect.promise(() => createHarness()); |
| @@ -1188,12 +1188,15 @@ const make = Effect.gen(function* () { · 1187 unmodified lines | ||
| 1188 | 1188 | return; |
| 1189 | 1189 | } |
| 1190 | 1190 | |
| 1191 | − const thread = yield* resolveThreadDetail(event.payload.threadId); | |
| 1191 | + const thread = yield* resolveThreadShell(event.payload.threadId); | |
| 1192 | 1192 | if (!thread) { |
| 1193 | 1193 | return; |
| 1194 | 1194 | } |
| 1195 | − const message = thread.messages.find((entry) => entry.id === event.payload.messageId); | |
| 1196 | − if (!message || message.role !== "user") { | |
| 1195 | + const turnStart = yield* projectionSnapshotQuery.getTurnStartMessage({ | |
| 1196 | + threadId: thread.id, | |
| 1197 | + messageId: event.payload.messageId, | |
| 1198 | + }); | |
| 1199 | + if (Option.isNone(turnStart) || turnStart.value.message.role !== "user") { | |
| 1197 | 1200 | yield* appendProviderFailureActivity({ |
| 1198 | 1201 | threadId: event.payload.threadId, |
| 1199 | 1202 | kind: "provider.turn.start.failed", |
| @@ -1205,6 +1208,7 @@ const make = Effect.gen(function* () { · 5 unmodified lines | ||
| 1205 | 1208 | }); |
| 1206 | 1209 | return; |
| 1207 | 1210 | } |
| 1211 | + const { message, hasOtherUserMessages } = turnStart.value; | |
| 1208 | 1212 | const appendTurnStartFailure = (summary: string, detail: string) => |
| 1209 | 1213 | appendProviderFailureActivity({ |
| 1210 | 1214 | threadId: event.payload.threadId, |
| @@ -1297,10 +1301,7 @@ const make = Effect.gen(function* () { · 86 unmodified lines | ||
| 1297 | 1301 | yield* ensureThreadWorktree(thread); |
| 1298 | 1302 | |
| 1299 | 1303 | const isCompactCommand = isCompactCommandMessage(message); |
| 1300 | − const nonCompactUserMessageCount = thread.messages.filter( | |
| 1301 | − (entry) => entry.role === "user" && !isCompactCommandMessage(entry), | |
| 1302 | − ).length; | |
| 1303 | − if (nonCompactUserMessageCount === 1 && !isCompactCommand) { | |
| 1304 | + if (!hasOtherUserMessages && !isCompactCommand) { | |
| 1304 | 1305 | const project = yield* resolveProject(thread.projectId); |
| 1305 | 1306 | const generationCwd = |
| 1306 | 1307 | resolveThreadWorkspaceCwd({ |
| @@ -1371,7 +1372,7 @@ const make = Effect.gen(function* () { · 64 unmodified lines | ||
| 1371 | 1372 | ), |
| 1372 | 1373 | ); |
| 1373 | 1374 | if (isCompactCommand) { |
| 1374 | − if (nonCompactUserMessageCount === 0) { | |
| 1375 | + if (!hasOtherUserMessages) { | |
| 1375 | 1376 | return yield* appendTurnStartFailure( |
| 1376 | 1377 | "Context compaction failed", |
| 1377 | 1378 | "Context compaction requires an existing conversation.", |
| @@ -10,7 +10,9 @@ import type { · 9 unmodified lines | ||
| 10 | 10 | AgentSessionImportSource, |
| 11 | 11 | ApprovalRequestId, |
| 12 | 12 | CheckpointRef, |
| 13 | + MessageId, | |
| 13 | 14 | OrchestrationCheckpointSummary, |
| 15 | + OrchestrationMessage, | |
| 14 | 16 | OrchestrationProject, |
| 15 | 17 | OrchestrationProjectShell, |
| 16 | 18 | OrchestrationReadModel, |
| @@ -211,6 +213,21 @@ export interface ProjectionSnapshotQueryShape { · 194 unmodified lines | ||
| 211 | 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 | 232 | * Read a single active thread detail snapshot by id. |
| 216 | 233 | */ |
| @@ -58,6 +58,7 @@ const makeProjectionSnapshotQueryLayer = (importedWorkspaceRoots: ReadonlyArray< · 57 unmodified lines | ||
| 58 | 58 | getFullThreadDiffContext: () => Effect.die("unused"), |
| 59 | 59 | getThreadShellById: () => Effect.die("unused"), |
| 60 | 60 | getThreadRuntimeContext: () => Effect.die("unused"), |
| 61 | + getTurnStartMessage: () => Effect.die("unused"), | |
| 61 | 62 | getThreadDetailById: () => Effect.die("unused"), |
| 62 | 63 | getThreadDetailSnapshot: () => Effect.die("unused"), |
| 63 | 64 | searchThreads: () => Effect.die("unused"), |
| @@ -46,6 +46,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => · 45 unmodified lines | ||
| 46 | 46 | getThreadCheckpointContext: () => Effect.die("unused"), |
| 47 | 47 | getFullThreadDiffContext: () => Effect.die("unused"), |
| 48 | 48 | getThreadRuntimeContext: () => Effect.die("unused"), |
| 49 | + getTurnStartMessage: () => Effect.die("unused"), | |
| 49 | 50 | getThreadShellById: () => Effect.die("unused"), |
| 50 | 51 | getThreadDetailById: () => Effect.die("unused"), |
| 51 | 52 | getThreadDetailSnapshot: () => Effect.die("unused"), |
| @@ -4330,6 +4330,7 @@ describe("agent browser access", () => { · 4329 unmodified lines | ||
| 4330 | 4330 | Layer.provide(runtimeRepositoryLayer), |
| 4331 | 4331 | ); |
| 4332 | 4332 | const projectionLayer = Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { |
| 4333 | + getTurnStartMessage: () => Effect.die("unused"), | |
| 4333 | 4334 | getImportedAgentSessionSources: () => Effect.die("unused"), |
| 4334 | 4335 | getUserInputActivity: () => Effect.die("unused"), |
| 4335 | 4336 | getCommandReadModel: () => Effect.die("unused"), |
| @@ -221,6 +221,7 @@ describe("ProviderSessionReaper", () => { · 220 unmodified lines | ||
| 221 | 221 | getThreadCheckpointContext: () => Effect.die("unused"), |
| 222 | 222 | getFullThreadDiffContext: () => Effect.die("unused"), |
| 223 | 223 | getThreadRuntimeContext: () => Effect.die("unused"), |
| 224 | + getTurnStartMessage: () => Effect.die("unused"), | |
| 224 | 225 | getThreadShellById: (threadId) => |
| 225 | 226 | Effect.succeed( |
| 226 | 227 | input.readModel.threads.find((thread) => thread.id === threadId) |
| @@ -171,6 +171,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa · 170 unmodified lines | ||
| 171 | 171 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), |
| 172 | 172 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), |
| 173 | 173 | getThreadRuntimeContext: () => Effect.die("unused"), |
| 174 | + getTurnStartMessage: () => Effect.die("unused"), | |
| 174 | 175 | getThreadShellById: () => Effect.die("unused"), |
| 175 | 176 | getThreadDetailById: () => Effect.die("unused"), |
| 176 | 177 | getThreadDetailSnapshot: () => Effect.die("unused"), |
| @@ -257,6 +258,7 @@ it.effect.each([ · 80 unmodified lines | ||
| 257 | 258 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), |
| 258 | 259 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), |
| 259 | 260 | getThreadRuntimeContext: () => Effect.die("unused"), |
| 261 | + getTurnStartMessage: () => Effect.die("unused"), | |
| 260 | 262 | getThreadShellById: () => Effect.die("unused"), |
| 261 | 263 | getThreadDetailById: () => Effect.die("unused"), |
| 262 | 264 | getThreadDetailSnapshot: () => Effect.die("unused"), |
| @@ -325,6 +327,7 @@ it.effect( · 62 unmodified lines | ||
| 325 | 327 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), |
| 326 | 328 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), |
| 327 | 329 | getThreadRuntimeContext: () => Effect.die("unused"), |
| 330 | + getTurnStartMessage: () => Effect.die("unused"), | |
| 328 | 331 | getThreadShellById: () => Effect.die("unused"), |
| 329 | 332 | getThreadDetailById: () => Effect.die("unused"), |
| 330 | 333 | getThreadDetailSnapshot: () => Effect.die("unused"), |
| @@ -386,6 +389,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa · 55 unmodified lines | ||
| 386 | 389 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), |
| 387 | 390 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), |
| 388 | 391 | getThreadRuntimeContext: () => Effect.die("unused"), |
| 392 | + getTurnStartMessage: () => Effect.die("unused"), | |
| 389 | 393 | getThreadShellById: () => Effect.die("unused"), |
| 390 | 394 | getThreadDetailById: () => Effect.die("unused"), |
| 391 | 395 | getThreadDetailSnapshot: () => Effect.die("unused"), |