Skip to content
Merged
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
29 changes: 28 additions & 1 deletion apps/server/src/codexAppServerManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,33 @@ describe("sendTurn", () => {
});
});

it("sends structured skill items to turn/start", async () => {
const { manager, context, sendRequest } = createSendTurnHarness();

await manager.sendTurn({
threadId: asThreadId("thread_1"),
input: "Use the review skill",
skills: [{ name: "review", path: "/Users/example/.codex/skills/review" }],
});

expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", {
threadId: "thread_1",
input: [
{
type: "text",
text: "Use the review skill",
text_elements: [],
},
{
type: "skill",
name: "review",
path: "/Users/example/.codex/skills/review",
},
],
model: "gpt-5.3-codex",
});
});

it("passes Codex plan mode as a collaboration preset on turn/start", async () => {
const { manager, context, sendRequest } = createSendTurnHarness();

Expand Down Expand Up @@ -644,7 +671,7 @@ describe("sendTurn", () => {
manager.sendTurn({
threadId: asThreadId("thread_1"),
}),
).rejects.toThrow("Turn input must include text or attachments.");
).rejects.toThrow("Turn input must include text, attachments, or skills.");
});
});

Expand Down
19 changes: 16 additions & 3 deletions apps/server/src/codexAppServerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
EventId,
ProviderItemId,
ProviderRequestKind,
type ProviderSkillReference,
type ProviderUserInputAnswers,
ThreadId,
TurnId,
Expand Down Expand Up @@ -109,6 +110,7 @@ export interface CodexAppServerSendTurnInput {
readonly threadId: ThreadId;
readonly input?: string;
readonly attachments?: ReadonlyArray<{ type: "image"; url: string }>;
readonly skills?: ReadonlyArray<ProviderSkillReference>;
readonly model?: string;
readonly serviceTier?: string | null;
readonly effort?: string;
Expand Down Expand Up @@ -658,7 +660,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
context.collabReceiverTurns.clear();

const turnInput: Array<
{ type: "text"; text: string; text_elements: [] } | { type: "image"; url: string }
| { type: "text"; text: string; text_elements: [] }
| { type: "image"; url: string }
| { type: "skill"; name: string; path: string }
> = [];
if (input.input) {
turnInput.push({
Expand All @@ -675,8 +679,15 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
});
}
}
for (const skill of input.skills ?? []) {
turnInput.push({
type: "skill",
name: skill.name,
path: skill.path,
});
}
if (turnInput.length === 0) {
throw new Error("Turn input must include text or attachments.");
throw new Error("Turn input must include text, attachments, or skills.");
}

const providerThreadId = readResumeThreadId({
Expand All @@ -690,7 +701,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
const turnStartParams: {
threadId: string;
input: Array<
{ type: "text"; text: string; text_elements: [] } | { type: "image"; url: string }
| { type: "text"; text: string; text_elements: [] }
| { type: "image"; url: string }
| { type: "skill"; name: string; path: string }
>;
model?: string;
serviceTier?: string | null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
EventId,
type ModelSelection,
type OrchestrationEvent,
type ProviderSkillReference,
ProviderKind,
type OrchestrationSession,
ThreadId,
Expand Down Expand Up @@ -362,6 +363,7 @@ const make = Effect.gen(function* () {
readonly threadId: ThreadId;
readonly messageText: string;
readonly attachments?: ReadonlyArray<ChatAttachment>;
readonly skills?: ReadonlyArray<ProviderSkillReference>;
readonly modelSelection?: ModelSelection;
readonly interactionMode?: "default" | "plan";
readonly createdAt: string;
Expand All @@ -380,6 +382,7 @@ const make = Effect.gen(function* () {
}
const normalizedInput = toNonEmptyProviderInput(input.messageText);
const normalizedAttachments = input.attachments ?? [];
const normalizedSkills = input.skills ?? [];
const activeSession = yield* providerService
.listSessions()
.pipe(
Expand All @@ -405,6 +408,7 @@ const make = Effect.gen(function* () {
threadId: input.threadId,
...(normalizedInput ? { input: normalizedInput } : {}),
...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),
...(normalizedSkills.length > 0 ? { skills: normalizedSkills } : {}),
...(modelForTurn !== undefined ? { modelSelection: modelForTurn } : {}),
...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),
});
Expand Down Expand Up @@ -569,6 +573,7 @@ const make = Effect.gen(function* () {
threadId: event.payload.threadId,
messageText: message.text,
...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
...(event.payload.skills !== undefined ? { skills: event.payload.skills } : {}),
...(event.payload.modelSelection !== undefined
? { modelSelection: event.payload.modelSelection }
: {}),
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
...(command.modelSelection !== undefined
? { modelSelection: command.modelSelection }
: {}),
...(command.skills !== undefined ? { skills: command.skills } : {}),
...(command.titleSeed !== undefined ? { titleSeed: command.titleSeed } : {}),
runtimeMode: targetThread.runtimeMode,
interactionMode: targetThread.interactionMode,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,52 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
export default Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;

// Older local databases may have already consumed migration id 20 for a
// different custom migration, which means the new auth base tables were never
// created. Make this migration self-healing so later ALTER steps do not fail.
yield* sql`
CREATE TABLE IF NOT EXISTS auth_pairing_links (
id TEXT PRIMARY KEY,
credential TEXT NOT NULL UNIQUE,
method TEXT NOT NULL,
role TEXT NOT NULL,
subject TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
consumed_at TEXT,
revoked_at TEXT,
label TEXT
)
`;

yield* sql`
CREATE INDEX IF NOT EXISTS idx_auth_pairing_links_active
ON auth_pairing_links(revoked_at, consumed_at, expires_at)
`;

yield* sql`
CREATE TABLE IF NOT EXISTS auth_sessions (
session_id TEXT PRIMARY KEY,
subject TEXT NOT NULL,
role TEXT NOT NULL,
method TEXT NOT NULL,
issued_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
revoked_at TEXT,
client_label TEXT,
client_ip_address TEXT,
client_user_agent TEXT,
client_device_type TEXT NOT NULL DEFAULT 'unknown',
client_os TEXT,
client_browser TEXT
)
`;

yield* sql`
CREATE INDEX IF NOT EXISTS idx_auth_sessions_active
ON auth_sessions(revoked_at, expires_at, issued_at)
`;

const pairingLinkColumns = yield* sql<{ readonly name: string }>`
PRAGMA table_info(auth_pairing_links)
`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,32 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
export default Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;

// Same recovery path as migration 021: ensure the auth sessions table exists
// even when migration 020 was skipped on an existing local database.
yield* sql`
CREATE TABLE IF NOT EXISTS auth_sessions (
session_id TEXT PRIMARY KEY,
subject TEXT NOT NULL,
role TEXT NOT NULL,
method TEXT NOT NULL,
issued_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
revoked_at TEXT,
client_label TEXT,
client_ip_address TEXT,
client_user_agent TEXT,
client_device_type TEXT NOT NULL DEFAULT 'unknown',
client_os TEXT,
client_browser TEXT,
last_connected_at TEXT
)
`;

yield* sql`
CREATE INDEX IF NOT EXISTS idx_auth_sessions_active
ON auth_sessions(revoked_at, expires_at, issued_at)
`;

const sessionColumns = yield* sql<{ readonly name: string }>`
PRAGMA table_info(auth_sessions)
`;
Expand Down
22 changes: 22 additions & 0 deletions apps/server/src/provider/Layers/CodexAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,28 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
});
}),
);

it.effect("forwards structured codex skills when present", () =>
Effect.gen(function* () {
sessionErrorManager.sendTurnImpl.mockClear();
const adapter = yield* CodexAdapter;

yield* Effect.ignore(
adapter.sendTurn({
threadId: asThreadId("sess-missing"),
input: "hello",
skills: [{ name: "review", path: "/Users/example/.codex/skills/review" }],
attachments: [],
}),
);

assert.deepStrictEqual(sessionErrorManager.sendTurnImpl.mock.calls[0]?.[0], {
threadId: asThreadId("sess-missing"),
input: "hello",
skills: [{ name: "review", path: "/Users/example/.codex/skills/review" }],
});
}),
);
});

const lifecycleManager = new FakeCodexManager();
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/provider/Layers/CodexAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1478,6 +1478,7 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
const managerInput = {
threadId: input.threadId,
...(input.input !== undefined ? { input: input.input } : {}),
...(input.skills !== undefined ? { skills: input.skills } : {}),
...(input.modelSelection?.provider === "codex"
? { model: input.modelSelection.model }
: {}),
Expand Down
6 changes: 4 additions & 2 deletions apps/server/src/provider/Layers/ProviderService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,18 +386,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
const input = {
...parsed,
attachments: parsed.attachments ?? [],
skills: parsed.skills ?? [],
};
if (!input.input && input.attachments.length === 0) {
if (!input.input && input.attachments.length === 0 && input.skills.length === 0) {
return yield* toValidationError(
"ProviderService.sendTurn",
"Either input text or at least one attachment is required",
"Either input text, at least one attachment, or at least one skill is required",
);
}
yield* Effect.annotateCurrentSpan({
"provider.operation": "send-turn",
"provider.thread_id": input.threadId,
"provider.interaction_mode": input.interactionMode,
"provider.attachment_count": input.attachments.length,
"provider.skill_count": input.skills.length,
});
let metricProvider = "unknown";
let metricModel = input.modelSelection?.model;
Expand Down
57 changes: 57 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1962,6 +1962,63 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("routes websocket rpc projects.listProviderCommands", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const workspaceDir = yield* fs.makeTempDirectoryScoped({
prefix: "t3-ws-project-provider-commands-",
});
yield* fs.makeDirectory(path.join(workspaceDir, ".codex", "skills", "review"), {
recursive: true,
});
yield* fs.writeFileString(
path.join(workspaceDir, ".codex", "skills", "review", "SKILL.md"),
"---\ndescription: Review staged changes\n---\n",
);

yield* buildAppUnderTest();

const wsUrl = yield* getWsServerUrl("/ws");
const response = yield* Effect.scoped(
withWsRpcClient(wsUrl, (client) =>
client[WS_METHODS.projectsListProviderCommands]({
cwd: workspaceDir,
provider: "codex",
}),
),
);

assert.equal(response.provider, "codex");
assert.isTrue(response.skills.some((entry) => entry.name === "review"));
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect(
"routes websocket rpc projects.listProviderCommands errors for invalid workspaces",
() =>
Effect.gen(function* () {
yield* buildAppUnderTest();

const wsUrl = yield* getWsServerUrl("/ws");
const result = yield* Effect.scoped(
withWsRpcClient(wsUrl, (client) =>
client[WS_METHODS.projectsListProviderCommands]({
cwd: "/definitely/not/a/real/workspace/path",
provider: "codex",
}),
).pipe(Effect.result),
);

assertTrue(result._tag === "Failure");
assertTrue(result.failure._tag === "ProviderCommandsListError");
assertInclude(
result.failure.message,
"Workspace root does not exist: /definitely/not/a/real/workspace/path",
);
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("routes websocket rpc projects.writeFile", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand Down
Loading
Loading