Skip to content
Open
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
2 changes: 2 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope,
[WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope,
[WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope,
[WS_METHODS.codexListSessions]: AuthOrchestrationReadScope,
[WS_METHODS.codexImportSessions]: AuthOrchestrationOperateScope,
[WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope,
[WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope,
[WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
expect(second.capabilities.repositoryIdentity).toBe(true);
expect(second.capabilities.connectionProbe).toBe(true);
expect(second.capabilities.threadTitleRegeneration).toBe(true);
expect(second.capabilities.codexSessionImport).toBe(true);
}),
);

Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export const make = Effect.gen(function* () {
threadSettlement: true,
threadSnooze: true,
threadTitleRegeneration: true,
codexSessionImport: true,
...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
...(serverSelfUpdate === "boot-service" || serverSelfUpdate === "respawn"
? { serverSelfUpdateProgress: true }
Expand Down
115 changes: 115 additions & 0 deletions apps/server/src/orchestration/decider.historyImport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import {
CommandId,
EventId,
MessageId,
ProjectId,
ProviderInstanceId,
ThreadId,
type OrchestrationCommand,
type OrchestrationEvent,
} from "@t3tools/contracts";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";

import { decideOrchestrationCommand } from "./decider.ts";
import { createEmptyReadModel, projectEvent } from "./projector.ts";

const NOW = "2026-01-01T00:00:00.000Z";
const PROJECT_ID = ProjectId.make("project-history-import");
const THREAD_ID = ThreadId.make("thread-history-import");

const seedReadModel = projectEvent(createEmptyReadModel(NOW), {
sequence: 1,
eventId: EventId.make("evt-project-history-import"),
aggregateKind: "project",
aggregateId: PROJECT_ID,
type: "project.created",
occurredAt: NOW,
commandId: CommandId.make("cmd-project-history-import"),
causationEventId: null,
correlationId: CommandId.make("cmd-project-history-import"),
metadata: {},
payload: {
projectId: PROJECT_ID,
title: "History Import",
workspaceRoot: "/tmp/history-import",
defaultModelSelection: null,
scripts: [],
createdAt: NOW,
updatedAt: NOW,
},
});

it.layer(NodeServices.layer)("history import decider", (it) => {
it.effect("creates a non-running thread and projects its text snapshot in order", () =>
Effect.gen(function* () {
const readModel = yield* seedReadModel;
const command = {
type: "thread.history.import",
commandId: CommandId.make("cmd-history-import"),
threadId: THREAD_ID,
projectId: PROJECT_ID,
title: "Codex conversation",
modelSelection: {
instanceId: ProviderInstanceId.make("codex"),
model: "gpt-5-codex",
},
runtimeMode: "full-access",
interactionMode: "default",
createdAt: NOW,
messages: [
{
messageId: MessageId.make("message-history-user"),
role: "user",
text: "Please inspect this project.",
turnId: null,
createdAt: "2026-01-01T00:01:00.000Z",
},
{
messageId: MessageId.make("message-history-assistant"),
role: "assistant",
text: "I found the relevant files.",
turnId: null,
createdAt: "2026-01-01T00:02:00.000Z",
},
],
} satisfies OrchestrationCommand;

const result = yield* decideOrchestrationCommand({ command, readModel });
const events = Array.isArray(result) ? result : [result];

expect(events.map((event) => event.type)).toEqual([
"thread.created",
"thread.message-sent",
"thread.message-sent",
]);
const created = events[0];
expect(created?.type).toBe("thread.created");
if (created?.type === "thread.created") {
expect(created.payload.branch).toBeNull();
expect(created.payload.worktreePath).toBeNull();
}
const messages = events.filter(
(event): event is Extract<OrchestrationEvent, { type: "thread.message-sent" }> =>
event.type === "thread.message-sent",
);
expect(messages.map((event) => event.payload.text)).toEqual([
"Please inspect this project.",
"I found the relevant files.",
]);
expect(messages.every((event) => event.payload.streaming === false)).toBe(true);

let projected = readModel;
for (const [index, event] of events.entries()) {
projected = yield* projectEvent(projected, { ...event, sequence: index + 2 });
}
const thread = projected.threads.find((entry) => entry.id === THREAD_ID);
expect(thread?.session).toBeNull();
expect(thread?.messages.map((message) => [message.role, message.text])).toEqual([
["user", "Please inspect this project."],
["assistant", "I found the relevant files."],
]);
}),
);
});
59 changes: 59 additions & 0 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,65 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
};
}

case "thread.history.import": {
yield* requireProject({
readModel,
command,
projectId: command.projectId,
});
yield* requireThreadAbsent({
readModel,
command,
threadId: command.threadId,
});

const created = {
...(yield* withEventBase({
aggregateKind: "thread",
aggregateId: command.threadId,
occurredAt: command.createdAt,
commandId: command.commandId,
})),
type: "thread.created" as const,
payload: {
threadId: command.threadId,
projectId: command.projectId,
title: command.title,
modelSelection: command.modelSelection,
runtimeMode: command.runtimeMode,
interactionMode: command.interactionMode,
branch: null,
worktreePath: null,
createdAt: command.createdAt,
updatedAt: command.createdAt,
},
};
const importedMessages = yield* Effect.forEach(command.messages, (message) =>
withEventBase({
aggregateKind: "thread",
aggregateId: command.threadId,
occurredAt: message.createdAt,
commandId: command.commandId,
}).pipe(
Effect.map((eventBase) => ({
...eventBase,
type: "thread.message-sent" as const,
payload: {
threadId: command.threadId,
messageId: message.messageId,
role: message.role,
text: message.text,
turnId: message.turnId,
streaming: false,
createdAt: message.createdAt,
updatedAt: message.createdAt,
},
})),
),
);
return [created, ...importedMessages];
}

case "thread.delete": {
yield* requireThread({
readModel,
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/provider/Drivers/CodexDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { ServerSettingsService } from "../../serverSettings.ts";
import { ProviderDriverError } from "../Errors.ts";
import { makeCodexAdapter } from "../Layers/CodexAdapter.ts";
import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts";
import { makeCodexThreadHistory } from "../Layers/CodexThreadHistory.ts";
import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts";
Expand Down Expand Up @@ -160,6 +161,11 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
environment: processEnv,
...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
});
const threadHistory = makeCodexThreadHistory({
config: effectiveConfig,
environment: processEnv,
spawner,
});
const textGeneration = yield* makeCodexTextGeneration(effectiveConfig, processEnv);

// Build a managed snapshot whose settings never change — mutations come
Expand Down Expand Up @@ -207,6 +213,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
enabled,
snapshot,
adapter,
threadHistory,
textGeneration,
} satisfies ProviderInstance;
}),
Expand Down
18 changes: 18 additions & 0 deletions apps/server/src/provider/Errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,24 @@ export class ProviderDriverError extends Schema.TaggedErrorClass<ProviderDriverE
}
}

/**
* ProviderThreadHistoryError - A provider's optional native-history bridge
* could not discover or read a persisted conversation.
*/
export class ProviderThreadHistoryError extends Schema.TaggedErrorClass<ProviderThreadHistoryError>()(
"ProviderThreadHistoryError",
{
provider: Schema.String,
operation: Schema.Literals(["list", "read"]),
detail: Schema.String,
cause: Schema.optional(Schema.Defect()),
},
) {
override get message(): string {
return `Provider thread history failed (${this.provider}) during ${this.operation}: ${this.detail}`;
}
}

/**
* ProviderSessionNotFoundError - Provider-facing session not found.
*/
Expand Down
Loading
Loading