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
72 changes: 61 additions & 11 deletions apps/server/src/services/agent-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -856,12 +856,33 @@ export class AgentService {
}

this.threadRepo.updateModel(thread.id, model);

if (provider === "codex" && codexFastMode !== undefined) {
this.threadRepo.updateSettings(thread.id, {
codex_fast_mode: codexFastMode,
});
}
this.threadRepo.updateSettings(thread.id, {
...(reasoningLevel !== undefined && { reasoning_level: reasoningLevel }),
...(interactionMode !== undefined && { interaction_mode: interactionMode }),
...(permissionMode !== undefined && permissionMode !== "default" && { permission_mode: permissionMode }),
...(contextWindowMode !== undefined && { context_window_mode: contextWindowMode }),
...(thinking !== undefined && { thinking }),
...(copilotAgent !== undefined && { copilot_agent: copilotAgent }),
...(provider === "codex" && codexFastMode !== undefined && { codex_fast_mode: codexFastMode }),
});
const persistedPermissionMode =
permissionMode === "full" || permissionMode === "supervised"
? permissionMode
: thread.permission_mode;
thread = {
...thread,
model,
provider,
reasoning_level: reasoningLevel ?? thread.reasoning_level,
interaction_mode: interactionMode ?? thread.interaction_mode,
permission_mode: persistedPermissionMode,
context_window_mode: contextWindowMode ?? thread.context_window_mode,
thinking: thinking ?? thread.thinking,
copilot_agent: copilotAgent ?? thread.copilot_agent,
codex_fast_mode: provider === "codex" && codexFastMode !== undefined
? codexFastMode
: thread.codex_fast_mode,
};

void this.sendMessage(
thread.id,
Expand Down Expand Up @@ -1017,6 +1038,39 @@ export class AgentService {
thread = this.threadRepo.create(workspaceId, title, "direct", branch, true, provider, lineage);
}

const resolvedCodexFast =
codexFastMode !== undefined
? codexFastMode
: parentThread.codex_fast_mode;
this.threadRepo.updateModel(thread.id, model);
this.threadRepo.updateSettings(thread.id, {
...(reasoningLevel !== undefined && { reasoning_level: reasoningLevel }),
...(interactionMode !== undefined && { interaction_mode: interactionMode }),
...(permissionMode !== undefined && permissionMode !== "default" && { permission_mode: permissionMode }),
...(effectiveContextWindowMode !== undefined && { context_window_mode: effectiveContextWindowMode }),
...(effectiveThinking !== undefined && { thinking: effectiveThinking }),
...(copilotAgent !== undefined && { copilot_agent: copilotAgent }),
...(provider === "codex" && resolvedCodexFast !== null && { codex_fast_mode: resolvedCodexFast }),
});
const persistedPermissionMode =
permissionMode === "full" || permissionMode === "supervised"
? permissionMode
: thread.permission_mode;
thread = {
...thread,
model,
provider,
reasoning_level: reasoningLevel ?? thread.reasoning_level,
interaction_mode: interactionMode ?? thread.interaction_mode,
permission_mode: persistedPermissionMode,
context_window_mode: effectiveContextWindowMode ?? thread.context_window_mode,
thinking: effectiveThinking ?? thread.thinking,
copilot_agent: copilotAgent ?? thread.copilot_agent,
codex_fast_mode: provider === "codex" && resolvedCodexFast !== null
? resolvedCodexFast
: thread.codex_fast_mode,
};

// Derive the fork anchor role for the pipeline.
const forkAnchorRole = forkMessage.role === "user" ? "user" : "assistant";

Expand Down Expand Up @@ -1219,10 +1273,6 @@ export class AgentService {
const providerInput =
interactionMode === "plan" ? this.buildPlanPrompt(providerWireOverride) : providerWireOverride;

const resolvedCodexFast =
codexFastMode !== undefined
? codexFastMode
: parentThread.codex_fast_mode;
if (provider === "codex" && resolvedCodexFast !== null) {
this.threadRepo.updateSettings(thread.id, {
codex_fast_mode: resolvedCodexFast,
Expand All @@ -1243,7 +1293,7 @@ export class AgentService {
copilotAgent,
effectiveContextWindowMode,
effectiveThinking,
undefined,
resolvedCodexFast ?? undefined,
undefined,
providerInput,
undefined,
Expand Down
81 changes: 80 additions & 1 deletion apps/web/src/__tests__/workspace-behavior.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,25 +408,104 @@ describe("Workspace Behavior", () => {
});
(mockTransport.createAndSendMessage as ReturnType<typeof vi.fn>).mockReturnValue(rpcPromise);

const done = useWorkspaceStore.getState().createAndSendMessage("Hello world", "composer-2-fast");
const done = useWorkspaceStore.getState().createAndSendMessage(
"Hello world",
"gpt-5.5",
"full",
undefined,
"high",
"codex",
"plan",
undefined,
undefined,
undefined,
true,
);
await Promise.resolve();

const mid = useWorkspaceStore.getState();
expect(mid.activeThreadId).not.toBeNull();
expect(mid.threads[0]?.clientPreparing).toBe(true);
expect(mid.threads[0]?.clientQueuedMessage).toBe("Hello world");
expect(mid.threads[0]?.model).toBe("gpt-5.5");
expect(mid.threads[0]?.provider).toBe("codex");
expect(mid.threads[0]?.reasoning_level).toBe("high");
expect(mid.threads[0]?.interaction_mode).toBe("plan");
expect(mid.threads[0]?.permission_mode).toBe("full");
expect(mid.threads[0]?.codex_fast_mode).toBe(true);

const created = createMockThread({
id: "server-thread-1",
workspace_id: ws.id,
title: "Hello world",
model: null,
provider: "claude",
reasoning_level: null,
});
resolveRpc(created);
await done;

const fin = useWorkspaceStore.getState();
expect(fin.threads.some((t) => t.id === "server-thread-1")).toBe(true);
expect(fin.activeThreadId).toBe("server-thread-1");
expect(fin.threads[0]?.model).toBe("gpt-5.5");
expect(fin.threads[0]?.provider).toBe("codex");
expect(fin.threads[0]?.reasoning_level).toBe("high");
});

it("branchThread placeholder preserves selected composer settings before the child exists", async () => {
const ws = createMockWorkspace({ id: "ws-branch-placeholder" });
const parent = createMockThread({ id: "parent-branch", workspace_id: ws.id });
let resolveRpc!: (value: ReturnType<typeof createMockThread>) => void;
const rpcPromise = new Promise<ReturnType<typeof createMockThread>>((resolve) => {
resolveRpc = resolve;
});

useWorkspaceStore.setState({
workspaces: [ws],
activeWorkspaceId: ws.id,
threads: [parent],
});
(mockTransport.createAndSendMessage as ReturnType<typeof vi.fn>).mockReturnValue(rpcPromise);

const done = useWorkspaceStore.getState().branchThread({
sourceThreadId: parent.id,
content: "Branch this",
model: "claude-opus-4-7",
provider: "claude",
mode: "direct",
forkedFromMessageId: "msg-parent",
permissionMode: "supervised",
reasoningLevel: "max",
interactionMode: "chat",
contextWindow: "1m",
thinking: true,
});
await Promise.resolve();

const mid = useWorkspaceStore.getState();
expect(mid.threads[0]?.clientPreparing).toBe(true);
expect(mid.threads[0]?.parent_thread_id).toBe(parent.id);
expect(mid.threads[0]?.model).toBe("claude-opus-4-7");
expect(mid.threads[0]?.provider).toBe("claude");
expect(mid.threads[0]?.reasoning_level).toBe("max");
expect(mid.threads[0]?.context_window_mode).toBe("1m");
expect(mid.threads[0]?.thinking).toBe(true);

resolveRpc(createMockThread({
id: "child-branch",
workspace_id: ws.id,
parent_thread_id: parent.id,
model: null,
reasoning_level: null,
}));
await done;

const fin = useWorkspaceStore.getState();
expect(fin.activeThreadId).toBe("child-branch");
expect(fin.threads[0]?.model).toBe("claude-opus-4-7");
expect(fin.threads[0]?.reasoning_level).toBe("max");
expect(fin.threads[0]?.context_window_mode).toBe("1m");
});

it("when createAndSendMessage succeeds after the user navigates away, activeThreadId is not forced to the new thread", async () => {
Expand Down
28 changes: 19 additions & 9 deletions apps/web/src/lib/workspace-thread.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Thread } from "@/transport";
import type { ContextWindowMode, ReasoningLevel } from "@mcode/contracts";

/**
* Thread row in the workspace store may include client-only fields while the
Expand Down Expand Up @@ -72,6 +73,15 @@ export function buildPlaceholderWorkspaceThread(params: {
transportMode: "direct" | "worktree";
branch: string;
clientPreparingContext: ClientPreparingContext;
model?: string | null;
provider?: string | null;
reasoningLevel?: ReasoningLevel | null;
interactionMode?: Thread["interaction_mode"];
permissionMode?: Thread["permission_mode"];
contextWindow?: ContextWindowMode | null;
thinking?: boolean | null;
codexFastMode?: boolean | null;
copilotAgent?: string | null;
parentThreadId?: string | null;
forkedFromMessageId?: string | null;
}): WorkspaceThread {
Expand All @@ -92,18 +102,18 @@ export function buildPlaceholderWorkspaceThread(params: {
sdk_session_id: null,
created_at: now,
updated_at: now,
model: null,
provider: "claude",
model: params.model ?? null,
provider: params.provider ?? "claude",
deleted_at: null,
last_context_tokens: null,
context_window: null,
reasoning_level: null,
interaction_mode: null,
permission_mode: null,
context_window_mode: null,
thinking: null,
codex_fast_mode: null,
copilot_agent: null,
reasoning_level: params.reasoningLevel ?? null,
interaction_mode: params.interactionMode ?? null,
permission_mode: params.permissionMode ?? null,
context_window_mode: params.contextWindow ?? null,
thinking: params.thinking ?? null,
codex_fast_mode: params.codexFastMode ?? null,
copilot_agent: params.copilotAgent ?? null,
parent_thread_id: params.parentThreadId ?? null,
forked_from_message_id: params.forkedFromMessageId ?? null,
last_compact_summary: null,
Expand Down
39 changes: 37 additions & 2 deletions apps/web/src/stores/workspaceStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ export const useWorkspaceStore = create<WorkspaceState>((set, get) => {
pendingThreadCreationByPlaceholderId.delete(placeholderId);
return;
}
const pending = pendingThreadCreationByPlaceholderId.get(placeholderId);
bumpThreadListMutationEpoch(workspaceId);
pendingThreadCreationByPlaceholderId.delete(placeholderId);
const startTime =
Expand All @@ -318,9 +319,25 @@ export const useWorkspaceStore = create<WorkspaceState>((set, get) => {
set((state) => {
const without = state.threads.filter((t) => t.id !== placeholderId);
const deduped = without.filter((t) => t.id !== thread.id);
const hydratedThread: WorkspaceThread = {
...thread,
model: pending?.model ?? thread.model ?? null,
provider: pending?.provider ?? thread.provider ?? "claude",
reasoning_level: pending?.reasoningLevel ?? thread.reasoning_level ?? null,
interaction_mode: pending?.interactionMode ?? thread.interaction_mode ?? null,
permission_mode: pending?.permissionMode ?? thread.permission_mode ?? null,
context_window_mode: pending?.contextWindow ?? thread.context_window_mode ?? null,
thinking: pending?.thinking ?? thread.thinking ?? null,
codex_fast_mode: (
pending?.provider === "codex" ? (pending.codexFastMode ?? null) : null
) ?? thread.codex_fast_mode ?? null,
copilot_agent: (
pending?.provider === "copilot" ? (pending.copilotAgent ?? null) : null
) ?? thread.copilot_agent ?? null,
};
const wt: WorkspaceThread = warnings?.length
? { ...thread, clientWarnings: warnings }
: thread;
? { ...hydratedThread, clientWarnings: warnings }
: hydratedThread;
const nextThreads: WorkspaceThread[] = [wt, ...deduped];
const stillOnPlaceholder = state.activeThreadId === placeholderId;
return {
Expand Down Expand Up @@ -736,6 +753,15 @@ export const useWorkspaceStore = create<WorkspaceState>((set, get) => {
transportMode: mode,
branch,
clientPreparingContext,
model,
provider,
reasoningLevel,
interactionMode,
permissionMode,
contextWindow,
thinking,
codexFastMode: provider === "codex" ? (codexFastMode ?? null) : null,
copilotAgent: provider === "copilot" ? (copilotAgent ?? null) : null,
});

bumpThreadListMutationEpoch(workspaceId);
Expand Down Expand Up @@ -820,6 +846,15 @@ export const useWorkspaceStore = create<WorkspaceState>((set, get) => {
transportMode,
branch,
clientPreparingContext,
model: params.model,
provider: params.provider,
reasoningLevel: params.reasoningLevel,
interactionMode: params.interactionMode,
permissionMode: params.permissionMode,
contextWindow: params.contextWindow,
thinking: params.thinking,
codexFastMode: params.provider === "codex" ? (params.codexFastMode ?? null) : null,
copilotAgent: params.provider === "copilot" ? (params.copilotAgent ?? null) : null,
parentThreadId: params.sourceThreadId,
forkedFromMessageId: params.forkedFromMessageId,
});
Expand Down
Loading