authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-06 02:17:16-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 02:17:16-07:00
log17490c0a00a6cf9a542fc8fa5d85f489fa662e70
tree3d9da94f8500074b2fd23455fa3fcb79747b74c0
parent281b92b48810062275798dd4107364384ebaf432
signature Signed by PGP key B5690EEEBB952194

perf(server): avoid full thread reads on turn start (#10108)


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 @@
1import {1import {
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";
12import * as NodeServices from "@effect/platform-node/NodeServices";13import * as NodeServices from "@effect/platform-node/NodeServices";
13import * as Effect from "effect/Effect";14import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";15import * as Layer from "effect/Layer";
16import * as Option from "effect/Option";
15import * as Schema from "effect/Schema";17import * as Schema from "effect/Schema";
16import * as SqlClient from "effect/unstable/sql/SqlClient";18import * as SqlClient from "effect/unstable/sql/SqlClient";
1719
...@@ -31,6 +33,9 @@ const asTurnId = (value: string): TurnId => TurnId.make(value);...@@ -31,6 +33,9 @@ const asTurnId = (value: string): TurnId => TurnId.make(value);
31const asMessageId = (value: string): MessageId => MessageId.make(value);33const asMessageId = (value: string): MessageId => MessageId.make(value);
32const asEventId = (value: string): EventId => EventId.make(value);34const asEventId = (value: string): EventId => EventId.make(value);
33const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value);35const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value);
36const encodeChatAttachments = Schema.encodeEffect(
37 Schema.fromJsonString(Schema.Array(ChatAttachment)),
38);
3439
35const projectionSnapshotLayer = it.layer(40const 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 );
600605
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 so91// 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.
93const THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE = 25;93const THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE = 25;
94// SQLite trim defaults to spaces. Match the whitespace removed by String.trim.
95const 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";
94const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(97const 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);
111const ProjectionTurnStartMessageDbRowSchema = ProjectionThreadMessageDbRowSchema.mapFields(
112 Struct.assign({ hasOtherUserMessages: Schema.Number }),
113);
108const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan;114const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan;
109const ProjectionThreadDbRowSchema = ProjectionThread.mapFields(115const 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({
180const ThreadIdLookupInput = Schema.Struct({186const ThreadIdLookupInput = Schema.Struct({
181 threadId: ThreadId,187 threadId: ThreadId,
182});188});
189const TurnStartMessageLookupInput = Schema.Struct({
190 threadId: ThreadId,
191 messageId: MessageId,
192});
183const ThreadActivityKindsLookupInput = Schema.Struct({193const 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 });
11191129
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 });
28892930
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 the2957 // Contiguous turn range bounding a windowed detail read; undefined loads the
2891 // full thread. Resolved from a window request inside the snapshot2958 // 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 );
911911
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 }
11901190
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);
12981302
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 ProjectionRepositoryError213 ProjectionRepositoryError
212 >;214 >;
213215
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"),