authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-28 12:27:45-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 23:00:41-08:00
log75354bbd962d9fc38a1ba40ee78520822b349016
tree5f47577128b4605a7d7db4f7fbfd301d41e7cf31
parent6a10e97a97f768137f7978375e4cfa13211b063e

Track runtime event sequence and order thread activities by sequence

- add per-session `sessionSequence` on provider runtime events and persist activity `sequence` - migrate `projection_thread_activities` with nullable `sequence` column + index - sort server/web activity projections by sequence fallback to timestamp/id - allow provider sessions/turns to start before a real threadId is emitted

17 files changed, 349 insertions(+), 69 deletions(-)

apps/server/src/orchestration/Layers/ProjectionPipeline.ts+3
...@@ -598,6 +598,9 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {...@@ -598,6 +598,9 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
598 kind: event.payload.activity.kind,598 kind: event.payload.activity.kind,
599 summary: event.payload.activity.summary,599 summary: event.payload.activity.summary,
600 payload: event.payload.activity.payload,600 payload: event.payload.activity.payload,
601 ...(event.payload.activity.sequence !== undefined
602 ? { sequence: event.payload.activity.sequence }
603 : {}),
601 createdAt: event.payload.activity.createdAt,604 createdAt: event.payload.activity.createdAt,
602 });605 });
603 return;606 return;
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+10-1
...@@ -2,6 +2,7 @@ import {...@@ -2,6 +2,7 @@ import {
2 ChatAttachment,2 ChatAttachment,
3 IsoDateTime,3 IsoDateTime,
4 MessageId,4 MessageId,
5 NonNegativeInt,
5 OrchestrationCheckpointFile,6 OrchestrationCheckpointFile,
6 OrchestrationReadModel,7 OrchestrationReadModel,
7 ProjectScript,8 ProjectScript,
...@@ -53,6 +54,7 @@ const ProjectionThreadDbRowSchema = ProjectionThread;...@@ -53,6 +54,7 @@ const ProjectionThreadDbRowSchema = ProjectionThread;
53const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(54const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(
54 Struct.assign({55 Struct.assign({
55 payload: Schema.fromJsonString(Schema.Unknown),56 payload: Schema.fromJsonString(Schema.Unknown),
57 sequence: Schema.NullOr(NonNegativeInt),
56 }),58 }),
57);59);
58const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession;60const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession;
...@@ -195,9 +197,15 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -195,9 +197,15 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
195 kind,197 kind,
196 summary,198 summary,
197 payload_json AS "payload",199 payload_json AS "payload",
200 sequence,
198 created_at AS "createdAt"201 created_at AS "createdAt"
199 FROM projection_thread_activities202 FROM projection_thread_activities
200 ORDER BY thread_id ASC, created_at ASC, activity_id ASC203 ORDER BY
204 thread_id ASC,
205 CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC,
206 sequence ASC,
207 created_at ASC,
208 activity_id ASC
201 `,209 `,
202 });210 });
203211
...@@ -398,6 +406,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -398,6 +406,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
398 summary: row.summary,406 summary: row.summary,
399 payload: row.payload,407 payload: row.payload,
400 turnId: row.turnId,408 turnId: row.turnId,
409 ...(row.sequence !== null ? { sequence: row.sequence } : {}),
401 createdAt: row.createdAt,410 createdAt: row.createdAt,
402 });411 });
403 activitiesByThread.set(row.threadId, threadActivities);412 activitiesByThread.set(row.threadId, threadActivities);
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+6
...@@ -79,6 +79,7 @@ function truncateDetail(value: string, limit = 180): string {...@@ -79,6 +79,7 @@ function truncateDetail(value: string, limit = 180): string {
79function runtimeEventToActivities(79function runtimeEventToActivities(
80 event: ProviderRuntimeEvent,80 event: ProviderRuntimeEvent,
81): ReadonlyArray<OrchestrationThreadActivity> {81): ReadonlyArray<OrchestrationThreadActivity> {
82 const maybeSequence = event.sessionSequence !== undefined ? { sequence: event.sessionSequence } : {};
82 switch (event.type) {83 switch (event.type) {
83 case "approval.requested": {84 case "approval.requested": {
84 return [85 return [
...@@ -97,6 +98,7 @@ function runtimeEventToActivities(...@@ -97,6 +98,7 @@ function runtimeEventToActivities(
97 ...(event.detail ? { detail: truncateDetail(event.detail) } : {}),98 ...(event.detail ? { detail: truncateDetail(event.detail) } : {}),
98 },99 },
99 turnId: toTurnId(event.turnId) ?? null,100 turnId: toTurnId(event.turnId) ?? null,
101 ...maybeSequence,
100 },102 },
101 ];103 ];
102 }104 }
...@@ -115,6 +117,7 @@ function runtimeEventToActivities(...@@ -115,6 +117,7 @@ function runtimeEventToActivities(
115 ...(event.decision ? { decision: event.decision } : {}),117 ...(event.decision ? { decision: event.decision } : {}),
116 },118 },
117 turnId: toTurnId(event.turnId) ?? null,119 turnId: toTurnId(event.turnId) ?? null,
120 ...maybeSequence,
118 },121 },
119 ];122 ];
120 }123 }
...@@ -131,6 +134,7 @@ function runtimeEventToActivities(...@@ -131,6 +134,7 @@ function runtimeEventToActivities(
131 message: truncateDetail(event.message),134 message: truncateDetail(event.message),
132 },135 },
133 turnId: toTurnId(event.turnId) ?? null,136 turnId: toTurnId(event.turnId) ?? null,
137 ...maybeSequence,
134 },138 },
135 ];139 ];
136 }140 }
...@@ -148,6 +152,7 @@ function runtimeEventToActivities(...@@ -148,6 +152,7 @@ function runtimeEventToActivities(
148 ...(event.detail ? { detail: truncateDetail(event.detail) } : {}),152 ...(event.detail ? { detail: truncateDetail(event.detail) } : {}),
149 },153 },
150 turnId: toTurnId(event.turnId) ?? null,154 turnId: toTurnId(event.turnId) ?? null,
155 ...maybeSequence,
151 },156 },
152 ];157 ];
153 }158 }
...@@ -165,6 +170,7 @@ function runtimeEventToActivities(...@@ -165,6 +170,7 @@ function runtimeEventToActivities(
165 ...(event.detail ? { detail: truncateDetail(event.detail) } : {}),170 ...(event.detail ? { detail: truncateDetail(event.detail) } : {}),
166 },171 },
167 turnId: toTurnId(event.turnId) ?? null,172 turnId: toTurnId(event.turnId) ?? null,
173 ...maybeSequence,
168 },174 },
169 ];175 ];
170 }176 }
apps/server/src/orchestration/projector.ts+18-1
...@@ -124,6 +124,23 @@ function retainThreadActivitiesAfterRevert(...@@ -124,6 +124,23 @@ function retainThreadActivitiesAfterRevert(
124 );124 );
125}125}
126126
127function compareThreadActivities(
128 left: OrchestrationThread["activities"][number],
129 right: OrchestrationThread["activities"][number],
130): number {
131 if (left.sequence !== undefined && right.sequence !== undefined) {
132 if (left.sequence !== right.sequence) {
133 return left.sequence - right.sequence;
134 }
135 } else if (left.sequence !== undefined) {
136 return 1;
137 } else if (right.sequence !== undefined) {
138 return -1;
139 }
140
141 return left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id);
142}
143
127export function createEmptyReadModel(nowIso: string): OrchestrationReadModel {144export function createEmptyReadModel(nowIso: string): OrchestrationReadModel {
128 return {145 return {
129 snapshotSequence: 0,146 snapshotSequence: 0,
...@@ -503,7 +520,7 @@ export function projectEvent(...@@ -503,7 +520,7 @@ export function projectEvent(
503 ...thread.activities.filter((entry) => entry.id !== payload.activity.id),520 ...thread.activities.filter((entry) => entry.id !== payload.activity.id),
504 payload.activity,521 payload.activity,
505 ]522 ]
506 .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt))523 .toSorted(compareThreadActivities)
507 .slice(-500);524 .slice(-500);
508525
509 return {526 return {
apps/server/src/persistence/Layers/ProjectionThreadActivities.ts+25-5
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as SqlSchema from "effect/unstable/sql/SqlSchema";2import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3import { NonNegativeInt } from "@t3tools/contracts";
3import { Effect, Layer, Schema, Struct } from "effect";4import { Effect, Layer, Schema, Struct } from "effect";
45
5import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts";6import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts";
...@@ -15,6 +16,7 @@ import {...@@ -15,6 +16,7 @@ import {
15const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(16const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(
16 Struct.assign({17 Struct.assign({
17 payload: Schema.fromJsonString(Schema.Unknown),18 payload: Schema.fromJsonString(Schema.Unknown),
19 sequence: Schema.NullOr(NonNegativeInt),
18 }),20 }),
19);21);
2022
...@@ -29,7 +31,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () {...@@ -29,7 +31,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () {
29 const sql = yield* SqlClient.SqlClient;31 const sql = yield* SqlClient.SqlClient;
3032
31 const upsertProjectionThreadActivityRow = SqlSchema.void({33 const upsertProjectionThreadActivityRow = SqlSchema.void({
32 Request: ProjectionThreadActivityDbRowSchema,34 Request: ProjectionThreadActivity,
33 execute: (row) =>35 execute: (row) =>
34 sql`36 sql`
35 INSERT INTO projection_thread_activities (37 INSERT INTO projection_thread_activities (
...@@ -40,6 +42,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () {...@@ -40,6 +42,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () {
40 kind,42 kind,
41 summary,43 summary,
42 payload_json,44 payload_json,
45 sequence,
43 created_at46 created_at
44 )47 )
45 VALUES (48 VALUES (
...@@ -49,7 +52,8 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () {...@@ -49,7 +52,8 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () {
49 ${row.tone},52 ${row.tone},
50 ${row.kind},53 ${row.kind},
51 ${row.summary},54 ${row.summary},
52 ${row.payload},55 ${JSON.stringify(row.payload)},
56 ${row.sequence ?? null},
53 ${row.createdAt}57 ${row.createdAt}
54 )58 )
55 ON CONFLICT (activity_id)59 ON CONFLICT (activity_id)
...@@ -60,6 +64,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () {...@@ -60,6 +64,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () {
60 kind = excluded.kind,64 kind = excluded.kind,
61 summary = excluded.summary,65 summary = excluded.summary,
62 payload_json = excluded.payload_json,66 payload_json = excluded.payload_json,
67 sequence = excluded.sequence,
63 created_at = excluded.created_at68 created_at = excluded.created_at
64 `,69 `,
65 });70 });
...@@ -77,10 +82,15 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () {...@@ -77,10 +82,15 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () {
77 kind,82 kind,
78 summary,83 summary,
79 payload_json AS "payload",84 payload_json AS "payload",
85 sequence,
80 created_at AS "createdAt"86 created_at AS "createdAt"
81 FROM projection_thread_activities87 FROM projection_thread_activities
82 WHERE thread_id = ${threadId}88 WHERE thread_id = ${threadId}
83 ORDER BY created_at ASC, activity_id ASC89 ORDER BY
90 CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC,
91 sequence ASC,
92 created_at ASC,
93 activity_id ASC
84 `,94 `,
85 });95 });
8696
...@@ -111,8 +121,18 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () {...@@ -111,8 +121,18 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () {
111 "ProjectionThreadActivityRepository.listByThreadId:decodeRows",121 "ProjectionThreadActivityRepository.listByThreadId:decodeRows",
112 ),122 ),
113 ),123 ),
114 Effect.map(124 Effect.map((rows) =>
115 (rows) => rows as ReadonlyArray<Schema.Schema.Type<typeof ProjectionThreadActivity>>,125 rows.map((row) => ({
126 activityId: row.activityId,
127 threadId: row.threadId,
128 turnId: row.turnId,
129 tone: row.tone,
130 kind: row.kind,
131 summary: row.summary,
132 payload: row.payload,
133 ...(row.sequence !== null ? { sequence: row.sequence } : {}),
134 createdAt: row.createdAt,
135 })),
116 ),136 ),
117 );137 );
118138
apps/server/src/persistence/Migrations.ts+2
...@@ -19,6 +19,7 @@ import Migration0004 from "./Migrations/004_ProviderSessionRuntime.ts";...@@ -19,6 +19,7 @@ import Migration0004 from "./Migrations/004_ProviderSessionRuntime.ts";
19import Migration0005 from "./Migrations/005_Projections.ts";19import Migration0005 from "./Migrations/005_Projections.ts";
20import Migration0006 from "./Migrations/006_ProjectionThreadSessionRuntimeModeColumns.ts";20import Migration0006 from "./Migrations/006_ProjectionThreadSessionRuntimeModeColumns.ts";
21import Migration0007 from "./Migrations/007_ProjectionThreadMessageAttachments.ts";21import Migration0007 from "./Migrations/007_ProjectionThreadMessageAttachments.ts";
22import Migration0008 from "./Migrations/008_ProjectionThreadActivitySequence.ts";
2223
23/**24/**
24 * Migration loader with all migrations defined inline.25 * Migration loader with all migrations defined inline.
...@@ -38,6 +39,7 @@ const loader = Migrator.fromRecord({...@@ -38,6 +39,7 @@ const loader = Migrator.fromRecord({
38 "5_Projections": Migration0005,39 "5_Projections": Migration0005,
39 "6_ProjectionThreadSessionRuntimeModeColumns": Migration0006,40 "6_ProjectionThreadSessionRuntimeModeColumns": Migration0006,
40 "7_ProjectionThreadMessageAttachments": Migration0007,41 "7_ProjectionThreadMessageAttachments": Migration0007,
42 "8_ProjectionThreadActivitySequence": Migration0008,
41});43});
4244
43/**45/**
apps/server/src/persistence/Migrations/008_ProjectionThreadActivitySequence.ts created+16
...@@ -0,0 +1,16 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6
7 yield* sql`
8 ALTER TABLE projection_thread_activities
9 ADD COLUMN sequence INTEGER
10 `;
11
12 yield* sql`
13 CREATE INDEX IF NOT EXISTS idx_projection_thread_activities_thread_sequence
14 ON projection_thread_activities(thread_id, sequence)
15 `;
16});
apps/server/src/persistence/Services/ProjectionThreadActivities.ts+4-1
...@@ -9,6 +9,7 @@...@@ -9,6 +9,7 @@
9import {9import {
10 EventId,10 EventId,
11 IsoDateTime,11 IsoDateTime,
12 NonNegativeInt,
12 OrchestrationThreadActivityTone,13 OrchestrationThreadActivityTone,
13 ThreadId,14 ThreadId,
14 TurnId,15 TurnId,
...@@ -26,6 +27,7 @@ export const ProjectionThreadActivity = Schema.Struct({...@@ -26,6 +27,7 @@ export const ProjectionThreadActivity = Schema.Struct({
26 kind: Schema.String,27 kind: Schema.String,
27 summary: Schema.String,28 summary: Schema.String,
28 payload: Schema.Unknown,29 payload: Schema.Unknown,
30 sequence: Schema.optional(NonNegativeInt),
29 createdAt: IsoDateTime,31 createdAt: IsoDateTime,
30});32});
31export type ProjectionThreadActivity = typeof ProjectionThreadActivity.Type;33export type ProjectionThreadActivity = typeof ProjectionThreadActivity.Type;
...@@ -57,7 +59,8 @@ export interface ProjectionThreadActivityRepositoryShape {...@@ -57,7 +59,8 @@ export interface ProjectionThreadActivityRepositoryShape {
57 /**59 /**
58 * List projected thread activity rows for a thread.60 * List projected thread activity rows for a thread.
59 *61 *
60 * Returned in ascending creation order.62 * Returned in ascending runtime sequence order (or creation order when
63 * sequence is unavailable).
61 */64 */
62 readonly listByThreadId: (65 readonly listByThreadId: (
63 input: ListProjectionThreadActivitiesInput,66 input: ListProjectionThreadActivitiesInput,
apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts+67
...@@ -505,6 +505,73 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -505,6 +505,73 @@ describe("ClaudeCodeAdapterLive", () => {
505 );505 );
506 });506 });
507507
508 it.effect("does not fabricate provider thread ids before first SDK session_id", () => {
509 const harness = makeHarness();
510 return Effect.gen(function* () {
511 const adapter = yield* ClaudeCodeAdapter;
512
513 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 5).pipe(
514 Stream.runCollect,
515 Effect.forkChild,
516 );
517
518 const session = yield* adapter.startSession({
519 provider: "claudeCode",
520 });
521 assert.equal(session.threadId, undefined);
522
523 const turn = yield* adapter.sendTurn({
524 sessionId: session.sessionId,
525 input: "hello",
526 attachments: [],
527 });
528 assert.equal(turn.threadId, undefined);
529
530 harness.query.emit({
531 type: "stream_event",
532 session_id: "sdk-thread-real",
533 uuid: "stream-thread-real",
534 parent_tool_use_id: null,
535 event: {
536 type: "message_start",
537 message: {
538 id: "msg-thread-real",
539 },
540 },
541 } as unknown as SDKMessage);
542
543 harness.query.emit({
544 type: "result",
545 subtype: "success",
546 is_error: false,
547 errors: [],
548 session_id: "sdk-thread-real",
549 uuid: "result-thread-real",
550 } as unknown as SDKMessage);
551
552 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
553 assert.deepEqual(
554 runtimeEvents.map((event) => event.type),
555 ["session.started", "turn.started", "thread.started", "message.completed", "turn.completed"],
556 );
557
558 const sessionStarted = runtimeEvents[0];
559 assert.equal(sessionStarted?.type, "session.started");
560 if (sessionStarted?.type === "session.started") {
561 assert.equal("threadId" in sessionStarted, false);
562 }
563
564 const threadStarted = runtimeEvents[2];
565 assert.equal(threadStarted?.type, "thread.started");
566 if (threadStarted?.type === "thread.started") {
567 assert.equal(threadStarted.threadId, "sdk-thread-real");
568 }
569 }).pipe(
570 Effect.provideService(Random.Random, makeDeterministicRandomService()),
571 Effect.provide(harness.layer),
572 );
573 });
574
508 it.effect("bridges approval request/response lifecycle through canUseTool", () => {575 it.effect("bridges approval request/response lifecycle through canUseTool", () => {
509 const harness = makeHarness();576 const harness = makeHarness();
510 return Effect.gen(function* () {577 return Effect.gen(function* () {
apps/server/src/provider/Layers/ClaudeCodeAdapter.ts+25-20
...@@ -126,6 +126,10 @@ function isUuid(value: string): boolean {...@@ -126,6 +126,10 @@ function isUuid(value: string): boolean {
126 );126 );
127}127}
128128
129function isSyntheticClaudeThreadId(value: string): boolean {
130 return value.startsWith("claude-thread-");
131}
132
129function toMessage(cause: unknown, fallback: string): string {133function toMessage(cause: unknown, fallback: string): string {
130 if (cause instanceof Error && cause.message.length > 0) {134 if (cause instanceof Error && cause.message.length > 0) {
131 return cause.message;135 return cause.message;
...@@ -158,7 +162,11 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef...@@ -158,7 +162,11 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef
158 turnCount?: unknown;162 turnCount?: unknown;
159 };163 };
160164
161 const threadId = typeof cursor.threadId === "string" ? cursor.threadId : undefined;165 const threadIdCandidate = typeof cursor.threadId === "string" ? cursor.threadId : undefined;
166 const threadId =
167 threadIdCandidate && !isSyntheticClaudeThreadId(threadIdCandidate)
168 ? threadIdCandidate
169 : undefined;
162 const resumeCandidate =170 const resumeCandidate =
163 typeof cursor.resume === "string"171 typeof cursor.resume === "string"
164 ? cursor.resume172 ? cursor.resume
...@@ -464,9 +472,14 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -464,9 +472,14 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
464 }>;472 }>;
465 }> =>473 }> =>
466 Effect.gen(function* () {474 Effect.gen(function* () {
467 const threadId =475 const threadId = context.session.threadId;
468 context.session.threadId ??476 if (!threadId) {
469 ProviderThreadId.makeUnsafe(`claude-thread-${yield* Random.nextUUIDv4}`);477 return yield* new ProviderAdapterValidationError({
478 provider: PROVIDER,
479 operation: "readThread",
480 issue: "Session thread id is not initialized yet.",
481 });
482 }
470 return {483 return {
471 threadId,484 threadId,
472 turns: context.turns.map((turn) => ({485 turns: context.turns.map((turn) => ({
...@@ -500,6 +513,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -500,6 +513,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
500 message: SDKMessage,513 message: SDKMessage,
501 ): Effect.Effect<void> =>514 ): Effect.Effect<void> =>
502 Effect.gen(function* () {515 Effect.gen(function* () {
516 if (typeof message.session_id !== "string" || message.session_id.length === 0) {
517 return;
518 }
503 const nextThreadId = ProviderThreadId.makeUnsafe(message.session_id);519 const nextThreadId = ProviderThreadId.makeUnsafe(message.session_id);
504 context.resumeSessionId = message.session_id;520 context.resumeSessionId = message.session_id;
505 const changed = context.session.threadId !== nextThreadId;521 const changed = context.session.threadId !== nextThreadId;
...@@ -922,9 +938,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -922,9 +938,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
922 `claude-session-${yield* Random.nextUUIDv4}`,938 `claude-session-${yield* Random.nextUUIDv4}`,
923 );939 );
924 const resumeState = readClaudeResumeState(input.resumeCursor);940 const resumeState = readClaudeResumeState(input.resumeCursor);
925 const threadId =941 const threadId = resumeState?.threadId;
926 resumeState?.threadId ??
927 ProviderThreadId.makeUnsafe(`claude-thread-${yield* Random.nextUUIDv4}`);
928942
929 const promptQueue = yield* Queue.unbounded<PromptQueueItem>();943 const promptQueue = yield* Queue.unbounded<PromptQueueItem>();
930 const prompt = Stream.fromQueue(promptQueue).pipe(944 const prompt = Stream.fromQueue(promptQueue).pipe(
...@@ -1082,9 +1096,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1082,9 +1096,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1082 status: "ready",1096 status: "ready",
1083 ...(input.cwd ? { cwd: input.cwd } : {}),1097 ...(input.cwd ? { cwd: input.cwd } : {}),
1084 ...(input.model ? { model: input.model } : {}),1098 ...(input.model ? { model: input.model } : {}),
1085 threadId,1099 ...(threadId ? { threadId } : {}),
1086 resumeCursor: {1100 resumeCursor: {
1087 threadId,1101 ...(threadId ? { threadId } : {}),
1088 ...(resumeState?.resume ? { resume: resumeState.resume } : {}),1102 ...(resumeState?.resume ? { resume: resumeState.resume } : {}),
1089 ...(resumeState?.resumeSessionAt1103 ...(resumeState?.resumeSessionAt
1090 ? { resumeSessionAt: resumeState.resumeSessionAt }1104 ? { resumeSessionAt: resumeState.resumeSessionAt }
...@@ -1119,7 +1133,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1119,7 +1133,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1119 provider: PROVIDER,1133 provider: PROVIDER,
1120 sessionId,1134 sessionId,
1121 createdAt: sessionStartedStamp.createdAt,1135 createdAt: sessionStartedStamp.createdAt,
1122 threadId,1136 ...(threadId ? { threadId } : {}),
1123 });1137 });
11241138
1125 Effect.runFork(runSdkStream(context));1139 Effect.runFork(runSdkStream(context));
...@@ -1186,17 +1200,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1186,17 +1200,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1186 message,1200 message,
1187 }).pipe(Effect.mapError((cause) => toRequestError(input.sessionId, "turn/start", cause)));1201 }).pipe(Effect.mapError((cause) => toRequestError(input.sessionId, "turn/start", cause)));
11881202
1189 const threadId = context.session.threadId;
1190 if (!threadId) {
1191 return yield* new ProviderAdapterValidationError({
1192 provider: PROVIDER,
1193 operation: "sendTurn",
1194 issue: "Session thread id is not initialized.",
1195 });
1196 }
1197
1198 return {1203 return {
1199 threadId,1204 ...(context.session.threadId ? { threadId: context.session.threadId } : {}),
1200 turnId,1205 turnId,
1201 ...(context.session.resumeCursor !== undefined1206 ...(context.session.resumeCursor !== undefined
1202 ? { resumeCursor: context.session.resumeCursor }1207 ? { resumeCursor: context.session.resumeCursor }
apps/server/src/provider/Layers/ProviderService.test.ts+77-20
...@@ -28,7 +28,6 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";...@@ -28,7 +28,6 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
28import {28import {
29 ProviderAdapterSessionNotFoundError,29 ProviderAdapterSessionNotFoundError,
30 ProviderSessionNotFoundError,30 ProviderSessionNotFoundError,
31 ProviderValidationError,
32 ProviderUnsupportedError,31 ProviderUnsupportedError,
33 type ProviderAdapterError,32 type ProviderAdapterError,
34} from "../Errors.ts";33} from "../Errors.ts";
...@@ -648,6 +647,62 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -648,6 +647,62 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
648 }),647 }),
649 );648 );
650649
650 it.effect("assigns monotonic per-session sequence numbers to canonical runtime events", () =>
651 Effect.gen(function* () {
652 const provider = yield* ProviderService;
653 const session = yield* provider.startSession(asThreadId("thread-seq"), {
654 provider: "codex",
655 });
656
657 const receivedRef = yield* Ref.make<Array<ProviderRuntimeEvent>>([]);
658 const consumer = yield* Stream.take(provider.streamEvents, 3).pipe(
659 Stream.runForEach((event) => Ref.update(receivedRef, (current) => [...current, event])),
660 Effect.forkChild,
661 );
662 yield* sleep(20);
663
664 fanout.codex.emit({
665 type: "tool.started",
666 eventId: asEventId("evt-seq-1"),
667 provider: "codex",
668 sessionId: session.sessionId,
669 createdAt: new Date().toISOString(),
670 threadId: asProviderThreadId("thread-1"),
671 turnId: asTurnId("turn-1"),
672 toolKind: "command",
673 title: "Command run",
674 });
675 fanout.codex.emit({
676 type: "tool.completed",
677 eventId: asEventId("evt-seq-2"),
678 provider: "codex",
679 sessionId: session.sessionId,
680 createdAt: new Date().toISOString(),
681 threadId: asProviderThreadId("thread-1"),
682 turnId: asTurnId("turn-1"),
683 toolKind: "command",
684 title: "Command run",
685 });
686 fanout.codex.emit({
687 type: "turn.completed",
688 eventId: asEventId("evt-seq-3"),
689 provider: "codex",
690 sessionId: session.sessionId,
691 createdAt: new Date().toISOString(),
692 threadId: asProviderThreadId("thread-1"),
693 turnId: asTurnId("turn-1"),
694 status: "completed",
695 });
696
697 yield* Fiber.join(consumer);
698 const received = yield* Ref.get(receivedRef);
699 assert.deepEqual(
700 received.map((event) => event.sessionSequence),
701 [1, 2, 3],
702 );
703 }),
704 );
705
651 it.effect("keeps subscriber delivery ordered and isolates failing subscribers", () =>706 it.effect("keeps subscriber delivery ordered and isolates failing subscribers", () =>
652 Effect.gen(function* () {707 Effect.gen(function* () {
653 const provider = yield* ProviderService;708 const provider = yield* ProviderService;
...@@ -746,9 +801,10 @@ validation.layer("ProviderServiceLive validation", (it) => {...@@ -746,9 +801,10 @@ validation.layer("ProviderServiceLive validation", (it) => {
746 }),801 }),
747 );802 );
748803
749 it.effect("fails startSession when adapter returns no threadId", () =>804 it.effect("accepts startSession when adapter has not emitted provider thread id yet", () =>
750 Effect.gen(function* () {805 Effect.gen(function* () {
751 const provider = yield* ProviderService;806 const provider = yield* ProviderService;
807 const runtimeRepository = yield* ProviderSessionRuntimeRepository;
752808
753 validation.codex.startSession.mockImplementationOnce((input: ProviderSessionStartInput) =>809 validation.codex.startSession.mockImplementationOnce((input: ProviderSessionStartInput) =>
754 Effect.sync(() => {810 Effect.sync(() => {
...@@ -757,27 +813,28 @@ validation.layer("ProviderServiceLive validation", (it) => {...@@ -757,27 +813,28 @@ validation.layer("ProviderServiceLive validation", (it) => {
757 sessionId: asSessionId("sess-missing-thread"),813 sessionId: asSessionId("sess-missing-thread"),
758 provider: "codex",814 provider: "codex",
759 status: "ready",815 status: "ready",
760 cwd: input.cwd ?? process.cwd(),816 cwd: input.cwd ?? process.cwd(),
761 createdAt: now,817 createdAt: now,
762 updatedAt: now,818 updatedAt: now,
763 } satisfies ProviderSession;819 } satisfies ProviderSession;
764 }),820 }),
765 );821 );
766822
767 const failure = yield* Effect.result(823 const session = yield* provider.startSession(asThreadId("thread-missing"), {
768 provider.startSession(asThreadId("thread-missing"), {824 provider: "codex",
769 provider: "codex",825 cwd: "/tmp/project",
770 cwd: "/tmp/project",826 });
771 }),
772 );
773827
774 assertFailure(828 assert.equal(session.sessionId, asSessionId("sess-missing-thread"));
775 failure,829 assert.equal(session.threadId, undefined);
776 new ProviderValidationError({830
777 operation: "ProviderService.startSession",831 const runtime = yield* runtimeRepository.getBySessionId({
778 issue: "Provider 'codex' returned a session without threadId.",832 providerSessionId: session.sessionId,
779 }),833 });
780 );834 assert.equal(Option.isSome(runtime), true);
835 if (Option.isSome(runtime)) {
836 assert.equal(runtime.value.providerThreadId, null);
837 }
781 }),838 }),
782 );839 );
783});840});
apps/server/src/provider/Layers/ProviderService.ts+38-14
...@@ -123,6 +123,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -123,6 +123,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
123 const routedSessionAliasesRef = yield* Ref.make<Map<ProviderSessionId, ProviderSessionId>>(123 const routedSessionAliasesRef = yield* Ref.make<Map<ProviderSessionId, ProviderSessionId>>(
124 new Map(),124 new Map(),
125 );125 );
126 const sessionRuntimeSequenceRef = yield* Ref.make<Map<ProviderSessionId, number>>(new Map());
126127
127 const canonicalizeRuntimeEventSession = (128 const canonicalizeRuntimeEventSession = (
128 event: ProviderRuntimeEvent,129 event: ProviderRuntimeEvent,
...@@ -143,6 +144,21 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -143,6 +144,21 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
143144
144 const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>145 const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>
145 canonicalizeRuntimeEventSession(event).pipe(146 canonicalizeRuntimeEventSession(event).pipe(
147 Effect.flatMap((canonicalEvent) =>
148 Ref.modify(sessionRuntimeSequenceRef, (current) => {
149 const next = new Map(current);
150 const currentSequence = next.get(canonicalEvent.sessionId) ?? 0;
151 const sessionSequence = currentSequence + 1;
152 next.set(canonicalEvent.sessionId, sessionSequence);
153 return [
154 {
155 ...canonicalEvent,
156 sessionSequence,
157 } satisfies ProviderRuntimeEvent,
158 next,
159 ] as const;
160 }),
161 ),
146 Effect.tap((canonicalEvent) =>162 Effect.tap((canonicalEvent) =>
147 canonicalEventLogger163 canonicalEventLogger
148 ? Effect.flatMap(164 ? Effect.flatMap(
...@@ -165,23 +181,16 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -165,23 +181,16 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
165181
166 const upsertSessionBinding = (182 const upsertSessionBinding = (
167 session: ProviderSession,183 session: ProviderSession,
168 operation: string,
169 threadId: ThreadId,184 threadId: ThreadId,
170 ) =>185 ) =>
171 Effect.gen(function* () {186 Effect.gen(function* () {
172 const providerThreadId = session.threadId;187 const providerThreadId = session.threadId;
173 if (!providerThreadId) {
174 return yield* toValidationError(
175 operation,
176 `Provider '${session.provider}' returned a session without threadId.`,
177 );
178 }
179188
180 yield* directory.upsert({189 yield* directory.upsert({
181 sessionId: session.sessionId,190 sessionId: session.sessionId,
182 provider: session.provider,191 provider: session.provider,
183 threadId,192 threadId,
184 providerThreadId,193 ...(providerThreadId !== undefined ? { providerThreadId } : {}),
185 status: toRuntimeStatus(session),194 status: toRuntimeStatus(session),
186 ...(session.resumeCursor !== undefined ? { resumeCursor: session.resumeCursor } : {}),195 ...(session.resumeCursor !== undefined ? { resumeCursor: session.resumeCursor } : {}),
187 runtimePayload: toRuntimePayloadFromSession(session),196 runtimePayload: toRuntimePayloadFromSession(session),
...@@ -214,6 +223,16 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -214,6 +223,16 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
214 return changed ? next : current;223 return changed ? next : current;
215 });224 });
216225
226 const clearSessionSequence = (sessionId: ProviderSessionId) =>
227 Ref.update(sessionRuntimeSequenceRef, (current) => {
228 if (!current.has(sessionId)) {
229 return current;
230 }
231 const next = new Map(current);
232 next.delete(sessionId);
233 return next;
234 });
235
217 const setAlias = (staleSessionId: ProviderSessionId, liveSessionId: ProviderSessionId) =>236 const setAlias = (staleSessionId: ProviderSessionId, liveSessionId: ProviderSessionId) =>
218 Ref.update(routedSessionAliasesRef, (current) => {237 Ref.update(routedSessionAliasesRef, (current) => {
219 const existing = current.get(staleSessionId);238 const existing = current.get(staleSessionId);
...@@ -262,14 +281,15 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -262,14 +281,15 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
262 if (existing) {281 if (existing) {
263 const existingProviderThreadId = yield* upsertSessionBinding(282 const existingProviderThreadId = yield* upsertSessionBinding(
264 existing,283 existing,
265 `${input.operation}:upsertExistingSession`,
266 input.binding.threadId,284 input.binding.threadId,
267 );285 );
268 yield* directory.upsert({286 yield* directory.upsert({
269 sessionId: input.staleSessionId,287 sessionId: input.staleSessionId,
270 provider: existing.provider,288 provider: existing.provider,
271 threadId: input.binding.threadId,289 threadId: input.binding.threadId,
272 providerThreadId: existingProviderThreadId,290 ...(existingProviderThreadId !== undefined
291 ? { providerThreadId: existingProviderThreadId }
292 : {}),
273 ...(existing.resumeCursor !== undefined ? { resumeCursor: existing.resumeCursor } : {}),293 ...(existing.resumeCursor !== undefined ? { resumeCursor: existing.resumeCursor } : {}),
274 });294 });
275 if (existing.sessionId !== input.staleSessionId) {295 if (existing.sessionId !== input.staleSessionId) {
...@@ -307,7 +327,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -307,7 +327,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
307327
308 const resumedProviderThreadId = yield* upsertSessionBinding(328 const resumedProviderThreadId = yield* upsertSessionBinding(
309 resumed,329 resumed,
310 `${input.operation}:upsertRecoveredSession`,
311 input.binding.threadId,330 input.binding.threadId,
312 );331 );
313332
...@@ -315,7 +334,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -315,7 +334,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
315 sessionId: input.staleSessionId,334 sessionId: input.staleSessionId,
316 provider: resumed.provider,335 provider: resumed.provider,
317 threadId: input.binding.threadId,336 threadId: input.binding.threadId,
318 providerThreadId: resumedProviderThreadId,337 ...(resumedProviderThreadId !== undefined
338 ? { providerThreadId: resumedProviderThreadId }
339 : {}),
319 ...(resumed.resumeCursor !== undefined ? { resumeCursor: resumed.resumeCursor } : {}),340 ...(resumed.resumeCursor !== undefined ? { resumeCursor: resumed.resumeCursor } : {}),
320 });341 });
321342
...@@ -428,7 +449,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -428,7 +449,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
428 );449 );
429 }450 }
430451
431 yield* upsertSessionBinding(session, "ProviderService.startSession", threadId);452 yield* upsertSessionBinding(session, threadId);
432453
433 return session;454 return session;
434 });455 });
...@@ -473,7 +494,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -473,7 +494,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
473 sessionId: input.sessionId,494 sessionId: input.sessionId,
474 provider: routed.adapter.provider,495 provider: routed.adapter.provider,
475 threadId,496 threadId,
476 providerThreadId: turn.threadId,497 ...(turn.threadId !== undefined ? { providerThreadId: turn.threadId } : {}),
477 status: "running",498 status: "running",
478 ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}),499 ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}),
479 runtimePayload: {500 runtimePayload: {
...@@ -533,9 +554,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -533,9 +554,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
533 if (routed.sessionId !== input.sessionId) {554 if (routed.sessionId !== input.sessionId) {
534 yield* directory.remove(routed.sessionId);555 yield* directory.remove(routed.sessionId);
535 yield* clearAliasesReferencing(routed.sessionId);556 yield* clearAliasesReferencing(routed.sessionId);
557 yield* clearSessionSequence(routed.sessionId);
536 }558 }
537 yield* directory.remove(input.sessionId);559 yield* directory.remove(input.sessionId);
538 yield* clearAliasesReferencing(input.sessionId);560 yield* clearAliasesReferencing(input.sessionId);
561 yield* clearSessionSequence(input.sessionId);
539 });562 });
540563
541 const listSessions: ProviderServiceShape["listSessions"] = () =>564 const listSessions: ProviderServiceShape["listSessions"] = () =>
...@@ -584,6 +607,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -584,6 +607,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
584 // Keep persisted session bindings so stale sessions can be resumed after607 // Keep persisted session bindings so stale sessions can be resumed after
585 // process restart via providerThreadId.608 // process restart via providerThreadId.
586 yield* Ref.set(routedSessionAliasesRef, new Map());609 yield* Ref.set(routedSessionAliasesRef, new Map());
610 yield* Ref.set(sessionRuntimeSequenceRef, new Map());
587 });611 });
588612
589 return {613 return {
apps/web/src/session-logic.test.ts+24
...@@ -17,6 +17,7 @@ function makeActivity(overrides: {...@@ -17,6 +17,7 @@ function makeActivity(overrides: {
17 tone?: OrchestrationThreadActivity["tone"];17 tone?: OrchestrationThreadActivity["tone"];
18 payload?: Record<string, unknown>;18 payload?: Record<string, unknown>;
19 turnId?: string;19 turnId?: string;
20 sequence?: number;
20}): OrchestrationThreadActivity {21}): OrchestrationThreadActivity {
21 const payload = overrides.payload ?? {};22 const payload = overrides.payload ?? {};
22 return {23 return {
...@@ -27,6 +28,7 @@ function makeActivity(overrides: {...@@ -27,6 +28,7 @@ function makeActivity(overrides: {
27 tone: overrides.tone ?? "tool",28 tone: overrides.tone ?? "tool",
28 payload,29 payload,
29 turnId: overrides.turnId ? TurnId.makeUnsafe(overrides.turnId) : null,30 turnId: overrides.turnId ? TurnId.makeUnsafe(overrides.turnId) : null,
31 ...(overrides.sequence !== undefined ? { sequence: overrides.sequence } : {}),
30 };32 };
31}33}
3234
...@@ -131,6 +133,28 @@ describe("deriveWorkLogEntries", () => {...@@ -131,6 +133,28 @@ describe("deriveWorkLogEntries", () => {
131 const entries = deriveWorkLogEntries(activities, undefined);133 const entries = deriveWorkLogEntries(activities, undefined);
132 expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]);134 expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]);
133 });135 });
136
137 it("orders work log by activity sequence when present", () => {
138 const activities: OrchestrationThreadActivity[] = [
139 makeActivity({
140 id: "second",
141 createdAt: "2026-02-23T00:00:03.000Z",
142 sequence: 2,
143 summary: "Tool call complete",
144 kind: "tool.completed",
145 }),
146 makeActivity({
147 id: "first",
148 createdAt: "2026-02-23T00:00:04.000Z",
149 sequence: 1,
150 summary: "Tool call complete",
151 kind: "tool.completed",
152 }),
153 ];
154
155 const entries = deriveWorkLogEntries(activities, undefined);
156 expect(entries.map((entry) => entry.id)).toEqual(["first", "second"]);
157 });
134});158});
135159
136describe("hasToolActivityForTurn", () => {160describe("hasToolActivityForTurn", () => {
apps/web/src/session-logic.ts+19-6
...@@ -91,9 +91,7 @@ export function derivePendingApprovals(...@@ -91,9 +91,7 @@ export function derivePendingApprovals(
91 activities: ReadonlyArray<OrchestrationThreadActivity>,91 activities: ReadonlyArray<OrchestrationThreadActivity>,
92): PendingApproval[] {92): PendingApproval[] {
93 const openByRequestId = new Map<ApprovalRequestId, PendingApproval>();93 const openByRequestId = new Map<ApprovalRequestId, PendingApproval>();
94 const ordered = [...activities].toSorted((left, right) =>94 const ordered = [...activities].toSorted(compareActivitiesByOrder);
95 left.createdAt.localeCompare(right.createdAt),
96 );
9795
98 for (const activity of ordered) {96 for (const activity of ordered) {
99 const payload =97 const payload =
...@@ -134,9 +132,7 @@ export function deriveWorkLogEntries(...@@ -134,9 +132,7 @@ export function deriveWorkLogEntries(
134 activities: ReadonlyArray<OrchestrationThreadActivity>,132 activities: ReadonlyArray<OrchestrationThreadActivity>,
135 latestTurnId: TurnId | undefined,133 latestTurnId: TurnId | undefined,
136): WorkLogEntry[] {134): WorkLogEntry[] {
137 const ordered = [...activities].toSorted((left, right) =>135 const ordered = [...activities].toSorted(compareActivitiesByOrder);
138 left.createdAt.localeCompare(right.createdAt),
139 );
140 return ordered136 return ordered
141 .filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true))137 .filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true))
142 .filter((activity) => activity.kind !== "tool.started")138 .filter((activity) => activity.kind !== "tool.started")
...@@ -159,6 +155,23 @@ export function deriveWorkLogEntries(...@@ -159,6 +155,23 @@ export function deriveWorkLogEntries(
159 });155 });
160}156}
161157
158function compareActivitiesByOrder(
159 left: OrchestrationThreadActivity,
160 right: OrchestrationThreadActivity,
161): number {
162 if (left.sequence !== undefined && right.sequence !== undefined) {
163 if (left.sequence !== right.sequence) {
164 return left.sequence - right.sequence;
165 }
166 } else if (left.sequence !== undefined) {
167 return 1;
168 } else if (right.sequence !== undefined) {
169 return -1;
170 }
171
172 return left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id);
173}
174
162export function hasToolActivityForTurn(175export function hasToolActivityForTurn(
163 activities: ReadonlyArray<OrchestrationThreadActivity>,176 activities: ReadonlyArray<OrchestrationThreadActivity>,
164 turnId: TurnId | null | undefined,177 turnId: TurnId | null | undefined,
packages/contracts/src/orchestration.ts+1
...@@ -212,6 +212,7 @@ export const OrchestrationThreadActivity = Schema.Struct({...@@ -212,6 +212,7 @@ export const OrchestrationThreadActivity = Schema.Struct({
212 summary: TrimmedNonEmptyString,212 summary: TrimmedNonEmptyString,
213 payload: Schema.Unknown,213 payload: Schema.Unknown,
214 turnId: Schema.NullOr(TurnId),214 turnId: Schema.NullOr(TurnId),
215 sequence: Schema.optional(NonNegativeInt),
215 createdAt: IsoDateTime,216 createdAt: IsoDateTime,
216});217});
217export type OrchestrationThreadActivity = typeof OrchestrationThreadActivity.Type;218export type OrchestrationThreadActivity = typeof OrchestrationThreadActivity.Type;
packages/contracts/src/provider.ts+1-1
...@@ -93,7 +93,7 @@ export const ProviderSendTurnInput = Schema.Struct({...@@ -93,7 +93,7 @@ export const ProviderSendTurnInput = Schema.Struct({
93export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type;93export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type;
9494
95export const ProviderTurnStartResult = Schema.Struct({95export const ProviderTurnStartResult = Schema.Struct({
96 threadId: ProviderThreadId,96 threadId: Schema.optional(ProviderThreadId),
97 turnId: ProviderTurnId,97 turnId: ProviderTurnId,
98 resumeCursor: Schema.optional(Schema.Unknown),98 resumeCursor: Schema.optional(Schema.Unknown),
99});99});
packages/contracts/src/providerRuntime.ts+13
...@@ -31,6 +31,7 @@ export const ProviderRuntimeSessionStartedEvent = Schema.Struct({...@@ -31,6 +31,7 @@ export const ProviderRuntimeSessionStartedEvent = Schema.Struct({
31 eventId: EventId,31 eventId: EventId,
32 provider: ProviderKind,32 provider: ProviderKind,
33 sessionId: ProviderSessionId,33 sessionId: ProviderSessionId,
34 sessionSequence: Schema.optional(NonNegativeInt),
34 createdAt: IsoDateTime,35 createdAt: IsoDateTime,
35 threadId: Schema.optional(ProviderThreadId),36 threadId: Schema.optional(ProviderThreadId),
36 message: Schema.optional(TrimmedNonEmptyStringSchema),37 message: Schema.optional(TrimmedNonEmptyStringSchema),
...@@ -42,6 +43,7 @@ export const ProviderRuntimeSessionExitedEvent = Schema.Struct({...@@ -42,6 +43,7 @@ export const ProviderRuntimeSessionExitedEvent = Schema.Struct({
42 eventId: EventId,43 eventId: EventId,
43 provider: ProviderKind,44 provider: ProviderKind,
44 sessionId: ProviderSessionId,45 sessionId: ProviderSessionId,
46 sessionSequence: Schema.optional(NonNegativeInt),
45 createdAt: IsoDateTime,47 createdAt: IsoDateTime,
46 threadId: Schema.optional(ProviderThreadId),48 threadId: Schema.optional(ProviderThreadId),
47 message: Schema.optional(TrimmedNonEmptyStringSchema),49 message: Schema.optional(TrimmedNonEmptyStringSchema),
...@@ -53,6 +55,7 @@ export const ProviderRuntimeThreadStartedEvent = Schema.Struct({...@@ -53,6 +55,7 @@ export const ProviderRuntimeThreadStartedEvent = Schema.Struct({
53 eventId: EventId,55 eventId: EventId,
54 provider: ProviderKind,56 provider: ProviderKind,
55 sessionId: ProviderSessionId,57 sessionId: ProviderSessionId,
58 sessionSequence: Schema.optional(NonNegativeInt),
56 createdAt: IsoDateTime,59 createdAt: IsoDateTime,
57 threadId: ProviderThreadId,60 threadId: ProviderThreadId,
58});61});
...@@ -63,6 +66,7 @@ export const ProviderRuntimeTurnStartedEvent = Schema.Struct({...@@ -63,6 +66,7 @@ export const ProviderRuntimeTurnStartedEvent = Schema.Struct({
63 eventId: EventId,66 eventId: EventId,
64 provider: ProviderKind,67 provider: ProviderKind,
65 sessionId: ProviderSessionId,68 sessionId: ProviderSessionId,
69 sessionSequence: Schema.optional(NonNegativeInt),
66 createdAt: IsoDateTime,70 createdAt: IsoDateTime,
67 threadId: Schema.optional(ProviderThreadId),71 threadId: Schema.optional(ProviderThreadId),
68 turnId: ProviderTurnId,72 turnId: ProviderTurnId,
...@@ -74,6 +78,7 @@ export const ProviderRuntimeTurnCompletedEvent = Schema.Struct({...@@ -74,6 +78,7 @@ export const ProviderRuntimeTurnCompletedEvent = Schema.Struct({
74 eventId: EventId,78 eventId: EventId,
75 provider: ProviderKind,79 provider: ProviderKind,
76 sessionId: ProviderSessionId,80 sessionId: ProviderSessionId,
81 sessionSequence: Schema.optional(NonNegativeInt),
77 createdAt: IsoDateTime,82 createdAt: IsoDateTime,
78 threadId: Schema.optional(ProviderThreadId),83 threadId: Schema.optional(ProviderThreadId),
79 turnId: Schema.optional(ProviderTurnId),84 turnId: Schema.optional(ProviderTurnId),
...@@ -87,6 +92,7 @@ export const ProviderRuntimeMessageDeltaEvent = Schema.Struct({...@@ -87,6 +92,7 @@ export const ProviderRuntimeMessageDeltaEvent = Schema.Struct({
87 eventId: EventId,92 eventId: EventId,
88 provider: ProviderKind,93 provider: ProviderKind,
89 sessionId: ProviderSessionId,94 sessionId: ProviderSessionId,
95 sessionSequence: Schema.optional(NonNegativeInt),
90 createdAt: IsoDateTime,96 createdAt: IsoDateTime,
91 threadId: Schema.optional(ProviderThreadId),97 threadId: Schema.optional(ProviderThreadId),
92 turnId: Schema.optional(ProviderTurnId),98 turnId: Schema.optional(ProviderTurnId),
...@@ -100,6 +106,7 @@ export const ProviderRuntimeMessageCompletedEvent = Schema.Struct({...@@ -100,6 +106,7 @@ export const ProviderRuntimeMessageCompletedEvent = Schema.Struct({
100 eventId: EventId,106 eventId: EventId,
101 provider: ProviderKind,107 provider: ProviderKind,
102 sessionId: ProviderSessionId,108 sessionId: ProviderSessionId,
109 sessionSequence: Schema.optional(NonNegativeInt),
103 createdAt: IsoDateTime,110 createdAt: IsoDateTime,
104 itemId: ProviderItemId,111 itemId: ProviderItemId,
105 threadId: Schema.optional(ProviderThreadId),112 threadId: Schema.optional(ProviderThreadId),
...@@ -112,6 +119,7 @@ export const ProviderRuntimeToolStartedEvent = Schema.Struct({...@@ -112,6 +119,7 @@ export const ProviderRuntimeToolStartedEvent = Schema.Struct({
112 eventId: EventId,119 eventId: EventId,
113 provider: ProviderKind,120 provider: ProviderKind,
114 sessionId: ProviderSessionId,121 sessionId: ProviderSessionId,
122 sessionSequence: Schema.optional(NonNegativeInt),
115 createdAt: IsoDateTime,123 createdAt: IsoDateTime,
116 threadId: Schema.optional(ProviderThreadId),124 threadId: Schema.optional(ProviderThreadId),
117 turnId: Schema.optional(ProviderTurnId),125 turnId: Schema.optional(ProviderTurnId),
...@@ -127,6 +135,7 @@ export const ProviderRuntimeToolCompletedEvent = Schema.Struct({...@@ -127,6 +135,7 @@ export const ProviderRuntimeToolCompletedEvent = Schema.Struct({
127 eventId: EventId,135 eventId: EventId,
128 provider: ProviderKind,136 provider: ProviderKind,
129 sessionId: ProviderSessionId,137 sessionId: ProviderSessionId,
138 sessionSequence: Schema.optional(NonNegativeInt),
130 createdAt: IsoDateTime,139 createdAt: IsoDateTime,
131 threadId: Schema.optional(ProviderThreadId),140 threadId: Schema.optional(ProviderThreadId),
132 turnId: Schema.optional(ProviderTurnId),141 turnId: Schema.optional(ProviderTurnId),
...@@ -142,6 +151,7 @@ export const ProviderRuntimeApprovalRequestedEvent = Schema.Struct({...@@ -142,6 +151,7 @@ export const ProviderRuntimeApprovalRequestedEvent = Schema.Struct({
142 eventId: EventId,151 eventId: EventId,
143 provider: ProviderKind,152 provider: ProviderKind,
144 sessionId: ProviderSessionId,153 sessionId: ProviderSessionId,
154 sessionSequence: Schema.optional(NonNegativeInt),
145 createdAt: IsoDateTime,155 createdAt: IsoDateTime,
146 threadId: Schema.optional(ProviderThreadId),156 threadId: Schema.optional(ProviderThreadId),
147 turnId: Schema.optional(ProviderTurnId),157 turnId: Schema.optional(ProviderTurnId),
...@@ -158,6 +168,7 @@ export const ProviderRuntimeApprovalResolvedEvent = Schema.Struct({...@@ -158,6 +168,7 @@ export const ProviderRuntimeApprovalResolvedEvent = Schema.Struct({
158 eventId: EventId,168 eventId: EventId,
159 provider: ProviderKind,169 provider: ProviderKind,
160 sessionId: ProviderSessionId,170 sessionId: ProviderSessionId,
171 sessionSequence: Schema.optional(NonNegativeInt),
161 createdAt: IsoDateTime,172 createdAt: IsoDateTime,
162 threadId: Schema.optional(ProviderThreadId),173 threadId: Schema.optional(ProviderThreadId),
163 turnId: Schema.optional(ProviderTurnId),174 turnId: Schema.optional(ProviderTurnId),
...@@ -173,6 +184,7 @@ export const ProviderRuntimeCheckpointCapturedEvent = Schema.Struct({...@@ -173,6 +184,7 @@ export const ProviderRuntimeCheckpointCapturedEvent = Schema.Struct({
173 eventId: EventId,184 eventId: EventId,
174 provider: ProviderKind,185 provider: ProviderKind,
175 sessionId: ProviderSessionId,186 sessionId: ProviderSessionId,
187 sessionSequence: Schema.optional(NonNegativeInt),
176 createdAt: IsoDateTime,188 createdAt: IsoDateTime,
177 threadId: ProviderThreadId,189 threadId: ProviderThreadId,
178 turnId: Schema.optional(ProviderTurnId),190 turnId: Schema.optional(ProviderTurnId),
...@@ -187,6 +199,7 @@ export const ProviderRuntimeErrorEvent = Schema.Struct({...@@ -187,6 +199,7 @@ export const ProviderRuntimeErrorEvent = Schema.Struct({
187 eventId: EventId,199 eventId: EventId,
188 provider: ProviderKind,200 provider: ProviderKind,
189 sessionId: ProviderSessionId,201 sessionId: ProviderSessionId,
202 sessionSequence: Schema.optional(NonNegativeInt),
190 createdAt: IsoDateTime,203 createdAt: IsoDateTime,
191 threadId: Schema.optional(ProviderThreadId),204 threadId: Schema.optional(ProviderThreadId),
192 turnId: Schema.optional(ProviderTurnId),205 turnId: Schema.optional(ProviderTurnId),