Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ function createProviderServiceHarness(
stopSession: () => unsupported(),
listSessions,
getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
readThread: () => unsupported(),
getInstanceInfo: (instanceId) =>
Effect.succeed({
instanceId,
Expand Down
107 changes: 107 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1432,6 +1432,113 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
}),
);

it.effect("projects an imported transcript into thread messages", () =>
Effect.gen(function* () {
const projectionPipeline = yield* OrchestrationProjectionPipeline;
const eventStore = yield* OrchestrationEventStore;
const sql = yield* SqlClient.SqlClient;
const now = "2026-01-01T00:00:00.000Z";
const threadId = ThreadId.make("thread-imported-transcript");

yield* eventStore.append({
type: "thread.created",
eventId: EventId.make("evt-it1"),
aggregateKind: "thread",
aggregateId: threadId,
occurredAt: now,
commandId: CommandId.make("cmd-it1"),
causationEventId: null,
correlationId: CorrelationId.make("cmd-it1"),
metadata: {},
payload: {
threadId,
projectId: ProjectId.make("project-imported-transcript"),
title: "Imported transcript",
modelSelection: {
instanceId: ProviderInstanceId.make("claudeAgent"),
model: "sonnet",
},
runtimeMode: "full-access",
branch: null,
worktreePath: null,
createdAt: now,
updatedAt: now,
},
});

yield* eventStore.append({
type: "thread.messages-imported",
eventId: EventId.make("evt-it2"),
aggregateKind: "thread",
aggregateId: threadId,
occurredAt: "2026-01-01T00:00:01.000Z",
commandId: CommandId.make("cmd-it2"),
causationEventId: null,
correlationId: CorrelationId.make("cmd-it2"),
metadata: {},
payload: {
threadId,
messages: [
{
messageId: MessageId.make(
"imported:claudeAgent:thread-imported-transcript:000000:uuid-1",
),
role: "user",
text: "Fix the flaky test",
createdAt: "2026-01-01T00:00:01.000Z",
updatedAt: "2026-01-01T00:00:01.000Z",
},
{
messageId: MessageId.make(
"imported:claudeAgent:thread-imported-transcript:000001:uuid-2",
),
role: "assistant",
text: "Looking at it",
createdAt: "2026-01-01T00:00:01.000Z",
updatedAt: "2026-01-01T00:00:01.000Z",
},
],
},
});

yield* projectionPipeline.bootstrap;

const messageRows = yield* sql<{
readonly messageId: string;
readonly role: string;
readonly text: string;
readonly turnId: string | null;
readonly isStreaming: number;
}>`
SELECT
message_id AS "messageId",
role,
text,
turn_id AS "turnId",
is_streaming AS "isStreaming"
FROM projection_thread_messages
WHERE thread_id = ${threadId}
ORDER BY message_id ASC
`;
assert.deepEqual(messageRows, [
{
messageId: "imported:claudeAgent:thread-imported-transcript:000000:uuid-1",
role: "user",
text: "Fix the flaky test",
turnId: null,
isStreaming: 0,
},
{
messageId: "imported:claudeAgent:thread-imported-transcript:000001:uuid-2",
role: "assistant",
text: "Looking at it",
turnId: null,
isStreaming: 0,
},
]);
}),
);

it.effect("settles a superseded running turn when a new turn becomes active", () =>
Effect.gen(function* () {
const projectionPipeline = yield* OrchestrationProjectionPipeline;
Expand Down
20 changes: 20 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -782,6 +782,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
}

case "thread.message-sent":
case "thread.messages-imported":
case "thread.proposed-plan-upserted":
case "thread.activity-appended":
case "thread.approval-response-requested":
Expand Down Expand Up @@ -916,6 +917,25 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
return;
}

case "thread.messages-imported": {
yield* Effect.forEach(
event.payload.messages,
(message) =>
projectionThreadMessageRepository.upsert({
messageId: message.messageId,
threadId: event.payload.threadId,
turnId: null,
role: message.role,
text: message.text,
isStreaming: false,
createdAt: message.createdAt,
updatedAt: message.updatedAt,
}),
{ concurrency: 1 },
).pipe(Effect.asVoid);
return;
}

case "thread.reverted": {
const existingRows = yield* projectionThreadMessageRepository.listByThreadId({
threadId: event.payload.threadId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ describe("ProviderCommandReactor", () => {
},
});
},
readThread: () => unsupported(),
rollbackConversation: () => unsupported(),
get streamEvents() {
return Stream.fromPubSub(runtimeEventPubSub);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ function toNonEmptyProviderInput(value: string | undefined): string | undefined
return normalized && normalized.length > 0 ? normalized : undefined;
}

function mapProviderSessionStatusToOrchestrationStatus(
export function mapProviderSessionStatusToOrchestrationStatus(
status: "connecting" | "ready" | "running" | "error" | "closed",
): OrchestrationSession["status"] {
switch (status) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ function createProviderServiceHarness() {
},
});
},
readThread: () => unsupported(),
rollbackConversation: () => unsupported(),
get streamEvents() {
return Stream.fromPubSub(runtimeEventPubSub);
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/orchestration/Schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
ThreadSnoozedPayload as ContractsThreadSnoozedPayloadSchema,
ThreadUnsnoozedPayload as ContractsThreadUnsnoozedPayloadSchema,
ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema,
ThreadMessagesImportedPayload as ContractsThreadMessagesImportedPayloadSchema,
ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema,
ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema,
ThreadTurnDiffCompletedPayload as ContractsThreadTurnDiffCompletedPayloadSchema,
Expand Down Expand Up @@ -44,6 +45,7 @@ export const ThreadSnoozedPayload = ContractsThreadSnoozedPayloadSchema;
export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema;

export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema;
export const MessagesImportedPayloadSchema = ContractsThreadMessagesImportedPayloadSchema;
export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema;
export const ThreadSessionSetPayload = ContractsThreadSessionSetPayloadSchema;
export const ThreadTurnDiffCompletedPayload = ContractsThreadTurnDiffCompletedPayloadSchema;
Expand Down
95 changes: 95 additions & 0 deletions apps/server/src/orchestration/decider.projectScripts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,4 +457,99 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => {
});
}),
);

it.effect("emits thread.messages-imported from thread.messages.import", () =>
Effect.gen(function* () {
const now = "2026-01-01T00:00:00.000Z";
const initial = createEmptyReadModel(now);
const withProject = yield* projectEvent(initial, {
sequence: 1,
eventId: asEventId("evt-project-create"),
aggregateKind: "project",
aggregateId: asProjectId("project-1"),
type: "project.created",
occurredAt: now,
commandId: CommandId.make("cmd-project-create"),
causationEventId: null,
correlationId: CommandId.make("cmd-project-create"),
metadata: {},
payload: {
projectId: asProjectId("project-1"),
title: "Project",
workspaceRoot: "/tmp/project",
defaultModelSelection: null,
scripts: [],
createdAt: now,
updatedAt: now,
},
});
const readModel = yield* projectEvent(withProject, {
sequence: 2,
eventId: asEventId("evt-thread-create"),
aggregateKind: "thread",
aggregateId: ThreadId.make("thread-1"),
type: "thread.created",
occurredAt: now,
commandId: CommandId.make("cmd-thread-create"),
causationEventId: null,
correlationId: CommandId.make("cmd-thread-create"),
metadata: {},
payload: {
threadId: ThreadId.make("thread-1"),
projectId: asProjectId("project-1"),
title: "Thread",
modelSelection: {
instanceId: ProviderInstanceId.make("claudeAgent"),
model: "sonnet",
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "full-access",
branch: null,
worktreePath: null,
createdAt: now,
updatedAt: now,
},
});

const messages = [
{
messageId: asMessageId("imported:claudeAgent:thread-1:000000:uuid-1"),
role: "user" as const,
text: "hello",
createdAt: now,
updatedAt: now,
},
{
messageId: asMessageId("imported:claudeAgent:thread-1:000001:uuid-2"),
role: "assistant" as const,
text: "hi",
createdAt: now,
updatedAt: now,
},
];

const result = yield* decideOrchestrationCommand({
command: {
type: "thread.messages.import",
commandId: CommandId.make("cmd-messages-import"),
threadId: ThreadId.make("thread-1"),
messages,
createdAt: now,
},
readModel,
});

const singleResult = Array.isArray(result) ? null : result;
if (singleResult === null) {
throw new Error("Expected a single messages-imported event.");
}
expect(singleResult).toMatchObject({
type: "thread.messages-imported",
payload: {
threadId: ThreadId.make("thread-1"),
messages,
},
});
}),
);
});
21 changes: 21 additions & 0 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -940,6 +940,27 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
};
}

case "thread.messages.import": {
yield* requireThread({
readModel,
command,
threadId: command.threadId,
});
return {
...(yield* withEventBase({
aggregateKind: "thread",
aggregateId: command.threadId,
occurredAt: command.createdAt,
commandId: command.commandId,
})),
type: "thread.messages-imported",
payload: {
threadId: command.threadId,
messages: command.messages,
},
};
}

case "thread.session.set": {
const thread = yield* requireThread({
readModel,
Expand Down
Loading
Loading