Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8de9d8a
feat(codex): add account-gated Daybreak selection
PixPMusic Sep 23, 2026
67ff753
fix(codex): keep Daybreak default helper private
PixPMusic Sep 23, 2026
f6ed5d0
fix(web): reserve Daybreak colors for multiple programs
PixPMusic Sep 23, 2026
dc57471
fix(codex): confirm unsupported Daybreak model switches
PixPMusic Sep 23, 2026
ad3bde6
fix(web): hide Daybreak icon when off
PixPMusic Sep 23, 2026
150ad72
fix(web): narrow Daybreak icon color after Off guard
PixPMusic Sep 23, 2026
b5b1f30
fix(web): label Daybreak-only composer control
PixPMusic Sep 23, 2026
6613de9
fix(codex): validate saved Daybreak against model access
PixPMusic Sep 23, 2026
5684d59
fix(codex): carry Daybreak across eligible accounts
PixPMusic Sep 23, 2026
f4cef15
fix(web): declare Daybreak program theme colors
PixPMusic Sep 25, 2026
8df17cb
fix(web): preserve unavailable Daybreak selection labels
PixPMusic Sep 25, 2026
e1a2c85
fix(web): retain Daybreak label alongside fast mode
PixPMusic Sep 25, 2026
c9c73e7
fix(web): announce unknown saved Daybreak selections
PixPMusic Sep 25, 2026
b26ec82
fix(web): preserve Daybreak resets in sticky model settings
PixPMusic Sep 25, 2026
479197d
fix(mobile): scope pending settings to their owning thread
PixPMusic Sep 25, 2026
18217f6
feat(tools): thread metadata tooling
PixPMusic Sep 23, 2026
ca3ce6c
fix(tools): preserve accepted thread metadata across requests
PixPMusic Sep 23, 2026
b9f5420
refactor(tools): distinguish thread read and rename failures
PixPMusic Sep 23, 2026
cd5e021
fix(codex): tolerate unknown access program shapes
PixPMusic Sep 23, 2026
f585383
refactor(server): supply MCP model metadata through invocation context
PixPMusic Sep 25, 2026
6c80643
refactor(server): retain MCP session module boundary
PixPMusic Sep 25, 2026
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
40 changes: 30 additions & 10 deletions apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { LegendListRenderItemProps } from "@legendapp/list/react-native";
import { useAtomSet, useAtomValue } from "@effect/atom-react";
import { AnimatedLegendList } from "@legendapp/list/reanimated";
import {
carryCodexCyberAccessProgram,
getProviderOptionCurrentLabel,
getProviderOptionCurrentValue,
getProviderOptionDescriptors,
Expand Down Expand Up @@ -433,15 +434,34 @@ function ThreadSettingsSessionProvider(
const pressModel = useCallback(
(option: ModelOption) => {
void Haptics.selectionAsync();
setPendingModel((current) =>
pendingModelAfterPress({
current,
pressed: option,
pressedIsApplied: isApplied(option),
}),
);
const pending = pendingModelAfterPress({
current: pendingModel,
pressed: option,
pressedIsApplied: isApplied(option),
});
if (!pending) {
setPendingModel(null);
return;
}
const { selection, didReset } = carryCodexCyberAccessProgram({
current: pendingModel?.selection ?? props.selectedModel,
next: pending.selection,
nextCapabilities: pending.capabilities,
});
if (didReset) {
Alert.alert(
"Switch models and reset Daybreak?",
"The selected model does not support your current Daybreak setting.",
[
{ text: "Cancel", style: "cancel" },
{ text: "OK", onPress: () => setPendingModel({ ...pending, selection }) },
],
);
return;
}
setPendingModel({ ...pending, selection });
},
[isApplied],
[isApplied, pendingModel, props.selectedModel],
);

const value = useMemo<ThreadSettingsSessionValue>(
Expand Down Expand Up @@ -1342,10 +1362,10 @@ export function ExistingThreadSettingsRouteScreen() {
return <View className="flex-1 bg-sheet" />;
}

const { ownerId: _ownerId, ...settings } = session;
const { ownerId, ...settings } = session;

return (
<ThreadSettingsSessionProvider {...settings}>
<ThreadSettingsSessionProvider key={ownerId} {...settings}>
<ThreadSettingsPickerNavigator onClose={() => navigation.goBack()} />
</ThreadSettingsSessionProvider>
);
Expand Down
2 changes: 2 additions & 0 deletions apps/server/integration/providerService.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import * as Stream from "effect/Stream";

import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts";
import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts";
import { makeProviderRegistryLayer } from "../src/provider/testUtils/providerRegistryMock.ts";
import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts";
import {
NoOpProviderEventLoggers,
Expand Down Expand Up @@ -94,6 +95,7 @@ const makeIntegrationFixture = (options?: { readonly analytics?: Layer.Layer<Ana
const shared = Layer.mergeAll(
directoryLayer,
Layer.succeed(ProviderAdapterRegistry, registry),
makeProviderRegistryLayer(),
ServerConfig.layerTest(cwd, cwd).pipe(Layer.provide(NodeServices.layer)),
ServerSettingsService.layerTest(DEFAULT_SERVER_SETTINGS),
options?.analytics ?? AnalyticsService.layerTest,
Expand Down
144 changes: 144 additions & 0 deletions apps/server/integration/threadMetadata.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { expect, it } from "@effect/vitest";
import {
CommandId,
EnvironmentId,
ProjectId,
ProviderInstanceId,
ThreadId,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import { McpSchema, McpServer } from "effect/unstable/ai";

import { ServerConfig } from "../src/config.ts";
import { ServerEnvironment } from "../src/environment/ServerEnvironment.ts";
import { McpInvocationContext } from "../src/mcp/McpInvocationContext.ts";
import { ThreadToolkitHandlersLive } from "../src/mcp/toolkits/thread/handlers.ts";
import { ThreadToolkit } from "../src/mcp/toolkits/thread/tools.ts";
import { OrchestrationEngineService } from "../src/orchestration/Services/OrchestrationEngine.ts";
import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts";
import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts";
import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts";
import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts";
import * as ThreadBackgroundLiveness from "../src/orchestration/ThreadBackgroundLiveness.ts";
import * as ThreadPlanProgress from "../src/orchestration/ThreadPlanProgress.ts";
import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts";
import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts";
import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts";
import { RepositoryIdentityResolver } from "../src/project/RepositoryIdentityResolver.ts";
import { ProviderRegistry } from "../src/provider/Services/ProviderRegistry.ts";

const EngineLayer = Layer.mergeAll(
OrchestrationEngineLive.pipe(
Layer.provide(OrchestrationProjectionSnapshotQueryLive),
Layer.provide(OrchestrationProjectionPipelineLive),
),
OrchestrationProjectionSnapshotQueryLive,
).pipe(
Layer.provideMerge(ThreadBackgroundLiveness.layer),
Layer.provide(ThreadPlanProgress.layer),
Layer.provide(OrchestrationEventStoreLive),
Layer.provide(OrchestrationCommandReceiptRepositoryLive),
Layer.provide(Layer.succeed(RepositoryIdentityResolver, { resolve: () => Effect.succeed(null) })),
Layer.provide(SqlitePersistenceMemory),
);

const TestLayer = McpServer.toolkit(ThreadToolkit).pipe(
Layer.provide(ThreadToolkitHandlersLive),
Layer.provideMerge(McpServer.McpServer.layer),
Layer.provideMerge(EngineLayer),
Layer.provide(
Layer.mergeAll(Layer.mock(ProviderRegistry)({}), Layer.mock(ServerEnvironment)({})),
),
Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-thread-metadata-test-" })),
Layer.provideMerge(NodeServices.layer),
);

it.effect("persists a scoped MCP rename and protects it from a late generated title", () =>
Effect.gen(function* () {
const engine = yield* OrchestrationEngineService;
const snapshots = yield* ProjectionSnapshotQuery;
const projectId = ProjectId.make("metadata-project");
const threadId = ThreadId.make("metadata-thread");
const otherThreadId = ThreadId.make("other-thread");
const instanceId = ProviderInstanceId.make("codex");
const createdAt = "2026-09-23T00:00:00.000Z";
yield* engine.dispatch({
type: "project.create",
commandId: CommandId.make("metadata-project-create"),
projectId,
title: "Metadata project",
workspaceRoot: "/workspace/metadata-project",
createdAt,
});
for (const id of [threadId, otherThreadId]) {
yield* engine.dispatch({
type: "thread.create",
commandId: CommandId.make(`create:${id}`),
threadId: id,
projectId,
title: "Original name",
modelSelection: { instanceId, model: "gpt-6" },
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: null,
createdAt,
});
}
const server = yield* McpServer.McpServer;
const renamed = yield* server.callTool({
name: "set_thread_name",
arguments: { name: " Agent-chosen name " },
});
expect(renamed.isError).toBe(false);
expect(renamed.content).toEqual([
{ type: "text", text: '{"id":"metadata-thread","name":"Agent-chosen name"}' },
]);

yield* engine.dispatch({
type: "thread.title.generate.complete",
commandId: CommandId.make("late-generated-title"),
threadId,
expectedTitle: "Original name",
expectedVersion: null,
title: "Late generated name",
needsRefinement: true,
});
const metadata = yield* server.callTool({
name: "get_thread_metadata",
arguments: { fields: ["name"] },
});
expect(metadata.isError).toBe(false);
expect(metadata.content).toEqual([{ type: "text", text: '{"name":"Agent-chosen name"}' }]);
const saved = Option.getOrThrow(yield* snapshots.getThreadShellById(threadId));
expect(saved.titleState).toMatchObject({ source: "manual", needsRefinement: false });
const other = Option.getOrThrow(yield* snapshots.getThreadShellById(otherThreadId));
expect(other.title).toBe("Original name");
}).pipe(
Effect.provideService(McpSchema.McpServerClient, {
clientId: 1,
clientCapabilities: {},
clientInfo: { name: "metadata-test", version: "1.0" },
protocolVersion: "2025-06-18",
initializePayload: {
protocolVersion: "2025-06-18",
capabilities: {},
clientInfo: { name: "metadata-test", version: "1.0" },
},
getClient: Effect.die("unused"),
}),
Effect.provideService(McpInvocationContext, {
environmentId: EnvironmentId.make("metadata-environment"),
threadId: ThreadId.make("metadata-thread"),
providerInstanceId: ProviderInstanceId.make("codex"),
providerSessionId: "metadata-session",
capabilities: new Set(["thread"] as const),
issuedAt: 1,
}),
Effect.scoped,
Effect.provide(TestLayer),
),
);
54 changes: 54 additions & 0 deletions apps/server/src/mcp/McpHttpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/uns
import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts";
import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts";
import * as ServerConfig from "../config.ts";
import { ServerEnvironment } from "../environment/ServerEnvironment.ts";
import { ProviderRegistry } from "../provider/Services/ProviderRegistry.ts";
import * as McpHttpServer from "./McpHttpServer.ts";
import * as McpInvocationContext from "./McpInvocationContext.ts";
import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";
Expand Down Expand Up @@ -63,6 +65,19 @@ const PullRequestsTestLayer = McpHttpServer.PullRequestsToolkitRegistrationLive.
),
);

const ThreadTestLayer = McpHttpServer.ThreadToolkitRegistrationLive.pipe(
Layer.provideMerge(McpServer.McpServer.layer),
Layer.provide(
Layer.mergeAll(
Layer.mock(ProjectionSnapshotQuery)({ getThreadShellById: () => Effect.succeedNone }),
Layer.mock(OrchestrationEngineService)({}),
Layer.mock(ProviderRegistry)({}),
Layer.mock(ServerEnvironment)({}),
NodeServices.layer,
),
),
);

const snapshotResult = {
url: "http://example.test/",
title: "Example",
Expand Down Expand Up @@ -464,6 +479,45 @@ it.effect(
}).pipe(Effect.provide(PullRequestsTestLayer)),
);

it.effect(
"registers thread metadata and rename tools with their capability and input schemas",
() =>
Effect.gen(function* () {
const server = yield* McpServer.McpServer;
expect(server.tools.map(({ tool }) => tool.name)).toEqual(
expect.arrayContaining(["get_thread_metadata", "set_thread_name"]),
);
const read = server.tools.find(({ tool }) => tool.name === "get_thread_metadata");
const rename = server.tools.find(({ tool }) => tool.name === "set_thread_name");
expect(read?.tool.annotations?.readOnlyHint).toBe(true);
expect(rename?.tool.annotations?.readOnlyHint).toBe(false);
expect(read?.tool.inputSchema).toMatchObject({
properties: {
fields: {
anyOf: expect.arrayContaining([
{ type: "array", items: expect.any(Object), description: expect.any(String) },
]),
},
},
});
for (const [name, args] of [
["get_thread_metadata", {}],
["set_thread_name", { name: "Name" }],
] as const) {
const denied = yield* server
.callTool({ name, arguments: args })
.pipe(
Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
Effect.provideService(McpSchema.McpServerClient, client),
);
expect(denied.isError).toBe(true);
expect(denied.content).toEqual([
{ type: "text", text: "MCP credential does not grant the thread capability." },
]);
}
}).pipe(Effect.provide(ThreadTestLayer)),
);

it.effect("keeps the snapshot text under the agent's output ceiling", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
13 changes: 12 additions & 1 deletion apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import packageJson from "../../package.json" with { type: "json" };
import * as ServerConfig from "../config.ts";
import * as DeviceService from "../device/DeviceService.ts";
import * as McpInvocationContext from "./McpInvocationContext.ts";
import * as McpProviderSession from "./McpProviderSession.ts";
import * as McpSessionRegistry from "./McpSessionRegistry.ts";
import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";
import {
Expand All @@ -32,6 +33,8 @@ import {
} from "./toolkits/preview/tools.ts";
import { PullRequestsToolkitHandlersLive } from "./toolkits/pullRequests/handlers.ts";
import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts";
import * as ThreadHandlers from "./toolkits/thread/handlers.ts";
import * as ThreadTools from "./toolkits/thread/tools.ts";
import {
DeviceScreenshotToolkitHandlersLive,
DeviceStandardToolkitHandlersLive,
Expand Down Expand Up @@ -102,7 +105,10 @@ const makeMcpAuthMiddleware = McpSessionRegistry.McpSessionRegistry.pipe(
return unauthorized;
}
return yield* httpEffect.pipe(
Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
Effect.provideService(
McpInvocationContext.McpInvocationContext,
McpProviderSession.withMcpRequestedModelSelection(invocation),
),
Effect.map(normalizeMcpHttpResponse),
);
}),
Expand Down Expand Up @@ -647,6 +653,10 @@ export const PullRequestsToolkitRegistrationLive = McpServer.toolkit(PullRequest
Layer.provide(PullRequestsToolkitHandlersLive),
);

export const ThreadToolkitRegistrationLive = McpServer.toolkit(ThreadTools.ThreadToolkit).pipe(
Layer.provide(ThreadHandlers.ThreadToolkitHandlersLive),
);

const DeviceStandardToolkitRegistrationLive = McpServer.toolkit(DeviceStandardToolkit).pipe(
Layer.provide(DeviceStandardToolkitHandlersLive),
);
Expand All @@ -670,5 +680,6 @@ const McpTransportLive = McpServer.layerHttp({
export const layer = Layer.mergeAll(
PreviewToolkitRegistrationLive,
PullRequestsToolkitRegistrationLive,
ThreadToolkitRegistrationLive,
DeviceToolkitRegistrationLive,
).pipe(Layer.provideMerge(McpTransportLive));
5 changes: 4 additions & 1 deletion apps/server/src/mcp/McpInvocationContext.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
type EnvironmentId,
type ModelSelection,
McpCapabilityUnavailableError,
PreviewAutomationUnavailableError,
type ProviderInstanceId,
Expand All @@ -8,13 +9,15 @@ import {
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";

export type McpCapability = "preview" | "device" | "pull-requests";
export type McpCapability = "preview" | "device" | "pull-requests" | "thread";

export interface McpInvocationScope {
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
readonly providerSessionId: string;
readonly providerInstanceId: ProviderInstanceId;
/** Latest adapter request captured at the authenticated HTTP boundary. */
readonly requestedModelSelection?: ModelSelection | null;
readonly capabilities: ReadonlySet<McpCapability>;
readonly issuedAt: number;
}
Expand Down
Loading
Loading