| 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 | 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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()), |
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+1| ... | ... | @@ -422,6 +422,7 @@ describe("OrchestrationEngine", () => { |
| 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()), |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+141| ... | ... | @@ -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"; |
| 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); |
| 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) => { |
| 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; |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+68| ... | ... | @@ -91,6 +91,9 @@ const THREAD_DETAIL_ACTIVITY_LIMIT = 500; |
| 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( |
| 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({ |
| 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* () { |
| 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 ( |
| 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 ( |
| 3326 | 3393 | getFullThreadDiffContext, |
| 3327 | 3394 | getThreadShellById, |
| 3328 | 3395 | getThreadRuntimeContext, |
| 3396 | getTurnStartMessage, | |
| 3329 | 3397 | getThreadDetailById, |
| 3330 | 3398 | getThreadDetailSnapshot, |
| 3331 | 3399 | } satisfies ProjectionSnapshotQueryShape; |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+42| ... | ... | @@ -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 | 954 | effectIt.effect("rejects /compact without conversation context", () => |
| 913 | 955 | Effect.gen(function* () { |
| 914 | 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 | 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* () { |
| 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* () { |
| 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* () { |
| 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.", |
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+17| ... | ... | @@ -10,7 +10,9 @@ import type { |
| 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 { |
| 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 | */ |
apps/server/src/project/AgentSessionScanner.test.ts+1| ... | ... | @@ -58,6 +58,7 @@ const makeProjectionSnapshotQueryLayer = (importedWorkspaceRoots: ReadonlyArray< |
| 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"), |
apps/server/src/project/ProjectSetupScriptRunner.test.ts+1| ... | ... | @@ -46,6 +46,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => |
| 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"), |
apps/server/src/provider/Layers/ProviderService.test.ts+1| ... | ... | @@ -4330,6 +4330,7 @@ describe("agent browser access", () => { |
| 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"), |
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1| ... | ... | @@ -221,6 +221,7 @@ describe("ProviderSessionReaper", () => { |
| 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) |
apps/server/src/serverRuntimeStartup.test.ts+4| ... | ... | @@ -171,6 +171,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa |
| 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([ |
| 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( |
| 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 |
| 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"), |