Commit 689dda5

Browse files
Persist thread runtime mode and handle mode-switch session restarts
- Add `thread.runtime-mode.set` -> `thread.runtime-mode-set` flow in decider/reactors/projectors - Persist runtime mode on thread/session projections with new DB migrations - Update provider and Codex integration tests to verify thread continuity across mode changes Co-authored-by: codex <codex@users.noreply.github.com>
1 parent 0a67f9d commit 689dda5

51 files changed

Lines changed: 743 additions & 173 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/server/integration/OrchestrationEngineHarness.integration.ts‎

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ import { ProviderUnsupportedError } from "../src/provider/Errors.ts";
3737
import{ProviderAdapterRegistry}from"../src/provider/Services/ProviderAdapterRegistry.ts";
3838
import{ProviderSessionDirectoryLive}from"../src/provider/Layers/ProviderSessionDirectory.ts";
3939
import{makeProviderServiceLive}from"../src/provider/Layers/ProviderService.ts";
40+
import{makeCodexAdapterLive}from"../src/provider/Layers/CodexAdapter.ts";
41+
import{CodexAdapter}from"../src/provider/Services/CodexAdapter.ts";
4042
import{ProviderService}from"../src/provider/Services/ProviderService.ts";
4143
import{CheckpointReactorLive}from"../src/orchestration/Layers/CheckpointReactor.ts";
4244
import{OrchestrationEngineLive}from"../src/orchestration/Layers/OrchestrationEngine.ts";
@@ -187,6 +189,7 @@ export interface OrchestrationIntegrationHarness {
187189

188190
interfaceMakeOrchestrationIntegrationHarnessOptions{
189191
readonlyprovider?: "codex"|"claudeCode";
192+
readonlyrealCodex?: boolean;
190193
}
191194

192195
exportconstmakeOrchestrationIntegrationHarness=(
@@ -195,10 +198,21 @@ export const makeOrchestrationIntegrationHarness = (
195198
Effect.gen(function*(){
196199
constsleep=(ms: number)=>Effect.sleep(ms);
197200
constprovider=options?.provider??"codex";
198-
constadapterHarness=yield*makeTestProviderAdapterHarness({
199-
provider,
200-
});
201-
201+
constuseRealCodex=options?.realCodex===true;
202+
constadapterHarness=useRealCodex
203+
? null
204+
: yield*makeTestProviderAdapterHarness({
205+
provider,
206+
});
207+
constfakeRegistry=adapterHarness
208+
? Layer.succeed(ProviderAdapterRegistry,{
209+
getByProvider: (resolvedProvider)=>
210+
resolvedProvider===adapterHarness.provider
211+
? Effect.succeed(adapterHarness.adapter)
212+
: Effect.fail(newProviderUnsupportedError({provider: resolvedProvider})),
213+
listProviders: ()=>Effect.succeed([adapterHarness.provider]),
214+
}astypeofProviderAdapterRegistry.Service)
215+
: null;
202216
constrootDir=fs.mkdtempSync(path.join(os.tmpdir(),"t3-orchestration-integration-"));
203217
constworkspaceDir=path.join(rootDir,"workspace");
204218
conststateDir=path.join(rootDir,"state");
@@ -207,14 +221,6 @@ export const makeOrchestrationIntegrationHarness = (
207221
fs.mkdirSync(stateDir,{recursive: true});
208222
initializeGitWorkspace(workspaceDir);
209223

210-
constregistry: typeofProviderAdapterRegistry.Service={
211-
getByProvider: (provider)=>
212-
provider===adapterHarness.provider
213-
? Effect.succeed(adapterHarness.adapter)
214-
: Effect.fail(newProviderUnsupportedError({ provider })),
215-
listProviders: ()=>Effect.succeed([adapterHarness.provider]),
216-
};
217-
218224
constpersistenceLayer=makeSqlitePersistenceLive(dbPath);
219225
constorchestrationLayer=OrchestrationEngineLive.pipe(
220226
Layer.provide(OrchestrationProjectionPipelineLive),
@@ -224,10 +230,33 @@ export const makeOrchestrationIntegrationHarness = (
224230
constproviderSessionDirectoryLayer=ProviderSessionDirectoryLive.pipe(
225231
Layer.provide(ProviderSessionRuntimeRepositoryLive),
226232
);
227-
constproviderLayer=makeProviderServiceLive().pipe(
228-
Layer.provide(providerSessionDirectoryLayer),
229-
Layer.provide(Layer.succeed(ProviderAdapterRegistry,registry)),
233+
constrealCodexRegistry=Layer.effect(
234+
ProviderAdapterRegistry,
235+
Effect.gen(function*(){
236+
constcodexAdapter=yield*CodexAdapter;
237+
return{
238+
getByProvider: (resolvedProvider)=>
239+
resolvedProvider==="codex"
240+
? Effect.succeed(codexAdapter)
241+
: Effect.fail(newProviderUnsupportedError({provider: resolvedProvider})),
242+
listProviders: ()=>Effect.succeed(["codex"]asconst),
243+
}astypeofProviderAdapterRegistry.Service;
244+
}),
245+
).pipe(
246+
Layer.provide(makeCodexAdapterLive()),
247+
Layer.provideMerge(ServerConfig.layerTest(workspaceDir,stateDir)),
248+
Layer.provideMerge(NodeServices.layer),
249+
Layer.provideMerge(providerSessionDirectoryLayer),
230250
);
251+
constproviderLayer=useRealCodex
252+
? makeProviderServiceLive().pipe(
253+
Layer.provide(providerSessionDirectoryLayer),
254+
Layer.provide(realCodexRegistry),
255+
)
256+
: makeProviderServiceLive().pipe(
257+
Layer.provide(providerSessionDirectoryLayer),
258+
Layer.provide(fakeRegistry!),
259+
);
231260

232261
construntimeServicesLayer=Layer.mergeAll(
233262
orchestrationLayer,
@@ -407,8 +436,8 @@ export const makeOrchestrationIntegrationHarness = (
407436
return{
408437
rootDir,
409438
workspaceDir,
410-
dbPath,
411-
adapterHarness,
439+
dbPath,
440+
adapterHarness: adapterHarnessasTestProviderAdapterHarness,
412441
engine,
413442
snapshotQuery,
414443
providerService,

‎apps/server/integration/TestProviderAdapter.integration.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
264264
sessionId,
265265
provider,
266266
status: "ready",
267+
runtimeMode: input.runtimeMode,
267268
threadId,
268269
cwd: input.cwd,
269270
resumeCursor: input.resumeCursor??{ sessionId },

‎apps/server/integration/orchestrationEngine.integration.test.ts‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,16 @@ function withHarness<A, E>(
9797
);
9898
}
9999

100+
functionwithRealCodexHarness<A,E>(
101+
use: (harness: OrchestrationIntegrationHarness)=>Effect.Effect<A,E>,
102+
){
103+
returnEffect.acquireUseRelease(
104+
makeOrchestrationIntegrationHarness({provider: "codex",realCodex: true}),
105+
use,
106+
(harness)=>harness.dispose,
107+
);
108+
}
109+
100110
constseedProjectAndThread=(harness: OrchestrationIntegrationHarness)=>
101111
Effect.gen(function*(){
102112
constcreatedAt=nowIso();
@@ -118,6 +128,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>
118128
projectId: PROJECT_ID,
119129
title: "Integration Thread",
120130
model: "gpt-5-codex",
131+
runtimeMode: "approval-required",
121132
branch: null,
122133
worktreePath: harness.workspaceDir,
123134
createdAt,
@@ -216,6 +227,97 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite +
216227
),
217228
);
218229

230+
it.live.skipIf(!process.env.CODEX_BINARY_PATH)(
231+
"keeps the same Codex provider thread across runtime mode switches",
232+
()=>
233+
withRealCodexHarness((harness)=>
234+
Effect.gen(function*(){
235+
constcreatedAt=nowIso();
236+
237+
yield*harness.engine.dispatch({
238+
type: "project.create",
239+
commandId: CommandId.makeUnsafe("cmd-project-create-real-codex"),
240+
projectId: PROJECT_ID,
241+
title: "Integration Project",
242+
workspaceRoot: harness.workspaceDir,
243+
defaultModel: "gpt-5.3-codex",
244+
createdAt,
245+
});
246+
247+
yield*harness.engine.dispatch({
248+
type: "thread.create",
249+
commandId: CommandId.makeUnsafe("cmd-thread-create-real-codex"),
250+
threadId: THREAD_ID,
251+
projectId: PROJECT_ID,
252+
title: "Integration Thread",
253+
model: "gpt-5.3-codex",
254+
runtimeMode: "full-access",
255+
branch: null,
256+
worktreePath: harness.workspaceDir,
257+
createdAt,
258+
});
259+
260+
yield*harness.engine.dispatch({
261+
type: "thread.turn.start",
262+
commandId: CommandId.makeUnsafe("cmd-turn-start-real-codex-1"),
263+
threadId: THREAD_ID,
264+
message: {
265+
messageId: asMessageId("msg-real-codex-1"),
266+
role: "user",
267+
text: "Reply with exactly ALPHA.",
268+
attachments: [],
269+
},
270+
runtimeMode: "full-access",
271+
createdAt: nowIso(),
272+
});
273+
274+
constfirstThread=yield*harness.waitForThread(
275+
THREAD_ID,
276+
(entry)=>
277+
entry.session?.status==="ready"&&
278+
entry.session.providerName==="codex"&&
279+
entry.session.providerThreadId!==null&&
280+
entry.messages.some(
281+
(message)=>message.role==="assistant"&&message.streaming===false,
282+
),
283+
180_000,
284+
);
285+
286+
constoriginalProviderThreadId=firstThread.session?.providerThreadId;
287+
assert.isNotNull(originalProviderThreadId);
288+
289+
yield*harness.engine.dispatch({
290+
type: "thread.turn.start",
291+
commandId: CommandId.makeUnsafe("cmd-turn-start-real-codex-2"),
292+
threadId: THREAD_ID,
293+
message: {
294+
messageId: asMessageId("msg-real-codex-2"),
295+
role: "user",
296+
text: "Reply with exactly BETA.",
297+
attachments: [],
298+
},
299+
runtimeMode: "approval-required",
300+
createdAt: nowIso(),
301+
});
302+
303+
constsecondThread=yield*harness.waitForThread(
304+
THREAD_ID,
305+
(entry)=>
306+
entry.session?.status==="ready"&&
307+
entry.session.providerName==="codex"&&
308+
entry.session.providerThreadId!==null&&
309+
entry.session.runtimeMode==="approval-required"&&
310+
entry.messages.some(
311+
(message)=>message.role==="assistant"&&message.text.includes("BETA"),
312+
),
313+
180_000,
314+
);
315+
316+
assert.equal(secondThread.session?.providerThreadId,originalProviderThreadId);
317+
}),
318+
),
319+
);
320+
219321
it.live("runs multi-turn file edits and persists checkpoint diffs",()=>
220322
withHarness((harness)=>
221323
Effect.gen(function*(){

‎apps/server/integration/providerService.integration.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ it.effect("replays typed runtime fixture events", () =>
122122
{
123123
provider: "codex",
124124
cwd: fixture.cwd,
125+
runtimeMode: "full-access",
125126
},
126127
);
127128
assert.equal((session.threadId??"").length>0,true);
@@ -155,6 +156,7 @@ it.effect("replays file-changing fixture turn events", () =>
155156
{
156157
provider: "codex",
157158
cwd: fixture.cwd,
159+
runtimeMode: "full-access",
158160
},
159161
);
160162
assert.equal((session.threadId??"").length>0,true);
@@ -192,6 +194,7 @@ it.effect("runs multi-turn tool/approval flow", () =>
192194
{
193195
provider: "codex",
194196
cwd: fixture.cwd,
197+
runtimeMode: "full-access",
195198
},
196199
);
197200
assert.equal((session.threadId??"").length>0,true);
@@ -244,6 +247,7 @@ it.effect("rolls back provider conversation state only", () =>
244247
{
245248
provider: "codex",
246249
cwd: fixture.cwd,
250+
runtimeMode: "full-access",
247251
},
248252
);
249253
assert.equal((session.threadId??"").length>0,true);

‎apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ function makeSnapshot(input: {
4343
projectId: input.projectId,
4444
title: "Thread",
4545
model: "gpt-5-codex",
46+
runtimeMode: "full-access",
4647
branch: null,
4748
worktreePath: input.worktreePath,
4849
latestTurn: {

‎apps/server/src/codexAppServerManager.test.ts‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import{describe,expect,it,vi}from"vitest";
2+
import{randomUUID}from"node:crypto";
3+
import{mkdtempSync,rmSync,writeFileSync}from"node:fs";
4+
importosfrom"node:os";
5+
importpathfrom"node:path";
26
import{ProviderSessionId}from"@t3tools/contracts";
37

48
import{
@@ -165,6 +169,7 @@ describe("startSession", () => {
165169
awaitexpect(
166170
manager.startSession({
167171
provider: "codex",
172+
runtimeMode: "full-access",
168173
}),
169174
).rejects.toThrow("cwd missing");
170175
expect(events).toHaveLength(1);
@@ -347,3 +352,84 @@ describe("thread checkpoint control", () => {
347352
});
348353
});
349354
});
355+
356+
describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume",()=>{
357+
it(
358+
"keeps prior thread history when resuming with a changed runtime mode",
359+
async()=>{
360+
constworkspaceDir=mkdtempSync(path.join(os.tmpdir(),"codex-live-resume-"));
361+
writeFileSync(path.join(workspaceDir,"README.md"),"hello\n","utf8");
362+
363+
constmanager=newCodexAppServerManager();
364+
365+
try{
366+
constfirstSession=awaitmanager.startSession({
367+
provider: "codex",
368+
cwd: workspaceDir,
369+
runtimeMode: "full-access",
370+
providerOptions: {
371+
codex: {
372+
binaryPath: process.env.CODEX_BINARY_PATH,
373+
...(process.env.CODEX_HOME_PATH
374+
? {homePath: process.env.CODEX_HOME_PATH}
375+
: {}),
376+
},
377+
},
378+
});
379+
380+
constfirstTurn=awaitmanager.sendTurn({
381+
sessionId: firstSession.sessionId,
382+
input: `Reply with exactly the word ALPHA ${randomUUID()}`,
383+
});
384+
385+
expect(firstTurn.threadId).toBe(firstSession.threadId);
386+
387+
awaitvi.waitFor(async()=>{
388+
constsnapshot=awaitmanager.readThread(firstSession.sessionId);
389+
expect(snapshot.turns.length).toBeGreaterThan(0);
390+
},{timeout: 120_000,interval: 1_000});
391+
392+
constfirstSnapshot=awaitmanager.readThread(firstSession.sessionId);
393+
constoriginalThreadId=firstSnapshot.threadId;
394+
constoriginalTurnCount=firstSnapshot.turns.length;
395+
396+
manager.stopSession(firstSession.sessionId);
397+
398+
constresumedSession=awaitmanager.startSession({
399+
provider: "codex",
400+
cwd: workspaceDir,
401+
runtimeMode: "approval-required",
402+
resumeCursor: firstSession.resumeCursor,
403+
providerOptions: {
404+
codex: {
405+
binaryPath: process.env.CODEX_BINARY_PATH,
406+
...(process.env.CODEX_HOME_PATH
407+
? {homePath: process.env.CODEX_HOME_PATH}
408+
: {}),
409+
},
410+
},
411+
});
412+
413+
expect(resumedSession.threadId).toBe(originalThreadId);
414+
415+
constresumedSnapshotBeforeTurn=awaitmanager.readThread(resumedSession.sessionId);
416+
expect(resumedSnapshotBeforeTurn.threadId).toBe(originalThreadId);
417+
expect(resumedSnapshotBeforeTurn.turns.length).toBeGreaterThanOrEqual(originalTurnCount);
418+
419+
awaitmanager.sendTurn({
420+
sessionId: resumedSession.sessionId,
421+
input: `Reply with exactly the word BETA ${randomUUID()}`,
422+
});
423+
424+
awaitvi.waitFor(async()=>{
425+
constsnapshot=awaitmanager.readThread(resumedSession.sessionId);
426+
expect(snapshot.turns.length).toBeGreaterThan(originalTurnCount);
427+
},{timeout: 120_000,interval: 1_000});
428+
}finally{
429+
manager.stopAll();
430+
rmSync(workspaceDir,{recursive: true,force: true});
431+
}
432+
},
433+
180_000,
434+
);
435+
});

‎apps/server/src/codexAppServerManager.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
204204
sessionId,
205205
provider: "codex",
206206
status: "connecting",
207+
runtimeMode: input.runtimeMode,
207208
model: normalizeCodexModelSlug(input.model),
208209
cwd: resolvedCwd,
209210
createdAt: now,

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Commit 689dda5

Browse files
Persist thread runtime mode and handle mode-switch session restarts
- Add `thread.runtime-mode.set` -> `thread.runtime-mode-set` flow in decider/reactors/projectors - Persist runtime mode on thread/session projections with new DB migrations - Update provider and Codex integration tests to verify thread continuity across mode changes Co-authored-by: codex <codex@users.noreply.github.com>
1 parent 0a67f9d commit 689dda5

51 files changed

Lines changed: 743 additions & 173 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/server/integration/OrchestrationEngineHarness.integration.ts‎

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ import { ProviderUnsupportedError } from "../src/provider/Errors.ts";
3737
import{ProviderAdapterRegistry}from"../src/provider/Services/ProviderAdapterRegistry.ts";
3838
import{ProviderSessionDirectoryLive}from"../src/provider/Layers/ProviderSessionDirectory.ts";
3939
import{makeProviderServiceLive}from"../src/provider/Layers/ProviderService.ts";
40+
import{makeCodexAdapterLive}from"../src/provider/Layers/CodexAdapter.ts";
41+
import{CodexAdapter}from"../src/provider/Services/CodexAdapter.ts";
4042
import{ProviderService}from"../src/provider/Services/ProviderService.ts";
4143
import{CheckpointReactorLive}from"../src/orchestration/Layers/CheckpointReactor.ts";
4244
import{OrchestrationEngineLive}from"../src/orchestration/Layers/OrchestrationEngine.ts";
@@ -187,6 +189,7 @@ export interface OrchestrationIntegrationHarness {
187189

188190
interfaceMakeOrchestrationIntegrationHarnessOptions{
189191
readonlyprovider?: "codex"|"claudeCode";
192+
readonlyrealCodex?: boolean;
190193
}
191194

192195
exportconstmakeOrchestrationIntegrationHarness=(
@@ -195,10 +198,21 @@ export const makeOrchestrationIntegrationHarness = (
195198
Effect.gen(function*(){
196199
constsleep=(ms: number)=>Effect.sleep(ms);
197200
constprovider=options?.provider??"codex";
198-
constadapterHarness=yield*makeTestProviderAdapterHarness({
199-
provider,
200-
});
201-
201+
constuseRealCodex=options?.realCodex===true;
202+
constadapterHarness=useRealCodex
203+
? null
204+
: yield*makeTestProviderAdapterHarness({
205+
provider,
206+
});
207+
constfakeRegistry=adapterHarness
208+
? Layer.succeed(ProviderAdapterRegistry,{
209+
getByProvider: (resolvedProvider)=>
210+
resolvedProvider===adapterHarness.provider
211+
? Effect.succeed(adapterHarness.adapter)
212+
: Effect.fail(newProviderUnsupportedError({provider: resolvedProvider})),
213+
listProviders: ()=>Effect.succeed([adapterHarness.provider]),
214+
}astypeofProviderAdapterRegistry.Service)
215+
: null;
202216
constrootDir=fs.mkdtempSync(path.join(os.tmpdir(),"t3-orchestration-integration-"));
203217
constworkspaceDir=path.join(rootDir,"workspace");
204218
conststateDir=path.join(rootDir,"state");
@@ -207,14 +221,6 @@ export const makeOrchestrationIntegrationHarness = (
207221
fs.mkdirSync(stateDir,{recursive: true});
208222
initializeGitWorkspace(workspaceDir);
209223

210-
constregistry: typeofProviderAdapterRegistry.Service={
211-
getByProvider: (provider)=>
212-
provider===adapterHarness.provider
213-
? Effect.succeed(adapterHarness.adapter)
214-
: Effect.fail(newProviderUnsupportedError({ provider })),
215-
listProviders: ()=>Effect.succeed([adapterHarness.provider]),
216-
};
217-
218224
constpersistenceLayer=makeSqlitePersistenceLive(dbPath);
219225
constorchestrationLayer=OrchestrationEngineLive.pipe(
220226
Layer.provide(OrchestrationProjectionPipelineLive),
@@ -224,10 +230,33 @@ export const makeOrchestrationIntegrationHarness = (
224230
constproviderSessionDirectoryLayer=ProviderSessionDirectoryLive.pipe(
225231
Layer.provide(ProviderSessionRuntimeRepositoryLive),
226232
);
227-
constproviderLayer=makeProviderServiceLive().pipe(
228-
Layer.provide(providerSessionDirectoryLayer),
229-
Layer.provide(Layer.succeed(ProviderAdapterRegistry,registry)),
233+
constrealCodexRegistry=Layer.effect(
234+
ProviderAdapterRegistry,
235+
Effect.gen(function*(){
236+
constcodexAdapter=yield*CodexAdapter;
237+
return{
238+
getByProvider: (resolvedProvider)=>
239+
resolvedProvider==="codex"
240+
? Effect.succeed(codexAdapter)
241+
: Effect.fail(newProviderUnsupportedError({provider: resolvedProvider})),
242+
listProviders: ()=>Effect.succeed(["codex"]asconst),
243+
}astypeofProviderAdapterRegistry.Service;
244+
}),
245+
).pipe(
246+
Layer.provide(makeCodexAdapterLive()),
247+
Layer.provideMerge(ServerConfig.layerTest(workspaceDir,stateDir)),
248+
Layer.provideMerge(NodeServices.layer),
249+
Layer.provideMerge(providerSessionDirectoryLayer),
230250
);
251+
constproviderLayer=useRealCodex
252+
? makeProviderServiceLive().pipe(
253+
Layer.provide(providerSessionDirectoryLayer),
254+
Layer.provide(realCodexRegistry),
255+
)
256+
: makeProviderServiceLive().pipe(
257+
Layer.provide(providerSessionDirectoryLayer),
258+
Layer.provide(fakeRegistry!),
259+
);
231260

232261
construntimeServicesLayer=Layer.mergeAll(
233262
orchestrationLayer,
@@ -407,8 +436,8 @@ export const makeOrchestrationIntegrationHarness = (
407436
return{
408437
rootDir,
409438
workspaceDir,
410-
dbPath,
411-
adapterHarness,
439+
dbPath,
440+
adapterHarness: adapterHarnessasTestProviderAdapterHarness,
412441
engine,
413442
snapshotQuery,
414443
providerService,

‎apps/server/integration/TestProviderAdapter.integration.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
264264
sessionId,
265265
provider,
266266
status: "ready",
267+
runtimeMode: input.runtimeMode,
267268
threadId,
268269
cwd: input.cwd,
269270
resumeCursor: input.resumeCursor??{ sessionId },

‎apps/server/integration/orchestrationEngine.integration.test.ts‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,16 @@ function withHarness<A, E>(
9797
);
9898
}
9999

100+
functionwithRealCodexHarness<A,E>(
101+
use: (harness: OrchestrationIntegrationHarness)=>Effect.Effect<A,E>,
102+
){
103+
returnEffect.acquireUseRelease(
104+
makeOrchestrationIntegrationHarness({provider: "codex",realCodex: true}),
105+
use,
106+
(harness)=>harness.dispose,
107+
);
108+
}
109+
100110
constseedProjectAndThread=(harness: OrchestrationIntegrationHarness)=>
101111
Effect.gen(function*(){
102112
constcreatedAt=nowIso();
@@ -118,6 +128,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>
118128
projectId: PROJECT_ID,
119129
title: "Integration Thread",
120130
model: "gpt-5-codex",
131+
runtimeMode: "approval-required",
121132
branch: null,
122133
worktreePath: harness.workspaceDir,
123134
createdAt,
@@ -216,6 +227,97 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite +
216227
),
217228
);
218229

230+
it.live.skipIf(!process.env.CODEX_BINARY_PATH)(
231+
"keeps the same Codex provider thread across runtime mode switches",
232+
()=>
233+
withRealCodexHarness((harness)=>
234+
Effect.gen(function*(){
235+
constcreatedAt=nowIso();
236+
237+
yield*harness.engine.dispatch({
238+
type: "project.create",
239+
commandId: CommandId.makeUnsafe("cmd-project-create-real-codex"),
240+
projectId: PROJECT_ID,
241+
title: "Integration Project",
242+
workspaceRoot: harness.workspaceDir,
243+
defaultModel: "gpt-5.3-codex",
244+
createdAt,
245+
});
246+
247+
yield*harness.engine.dispatch({
248+
type: "thread.create",
249+
commandId: CommandId.makeUnsafe("cmd-thread-create-real-codex"),
250+
threadId: THREAD_ID,
251+
projectId: PROJECT_ID,
252+
title: "Integration Thread",
253+
model: "gpt-5.3-codex",
254+
runtimeMode: "full-access",
255+
branch: null,
256+
worktreePath: harness.workspaceDir,
257+
createdAt,
258+
});
259+
260+
yield*harness.engine.dispatch({
261+
type: "thread.turn.start",
262+
commandId: CommandId.makeUnsafe("cmd-turn-start-real-codex-1"),
263+
threadId: THREAD_ID,
264+
message: {
265+
messageId: asMessageId("msg-real-codex-1"),
266+
role: "user",
267+
text: "Reply with exactly ALPHA.",
268+
attachments: [],
269+
},
270+
runtimeMode: "full-access",
271+
createdAt: nowIso(),
272+
});
273+
274+
constfirstThread=yield*harness.waitForThread(
275+
THREAD_ID,
276+
(entry)=>
277+
entry.session?.status==="ready"&&
278+
entry.session.providerName==="codex"&&
279+
entry.session.providerThreadId!==null&&
280+
entry.messages.some(
281+
(message)=>message.role==="assistant"&&message.streaming===false,
282+
),
283+
180_000,
284+
);
285+
286+
constoriginalProviderThreadId=firstThread.session?.providerThreadId;
287+
assert.isNotNull(originalProviderThreadId);
288+
289+
yield*harness.engine.dispatch({
290+
type: "thread.turn.start",
291+
commandId: CommandId.makeUnsafe("cmd-turn-start-real-codex-2"),
292+
threadId: THREAD_ID,
293+
message: {
294+
messageId: asMessageId("msg-real-codex-2"),
295+
role: "user",
296+
text: "Reply with exactly BETA.",
297+
attachments: [],
298+
},
299+
runtimeMode: "approval-required",
300+
createdAt: nowIso(),
301+
});
302+
303+
constsecondThread=yield*harness.waitForThread(
304+
THREAD_ID,
305+
(entry)=>
306+
entry.session?.status==="ready"&&
307+
entry.session.providerName==="codex"&&
308+
entry.session.providerThreadId!==null&&
309+
entry.session.runtimeMode==="approval-required"&&
310+
entry.messages.some(
311+
(message)=>message.role==="assistant"&&message.text.includes("BETA"),
312+
),
313+
180_000,
314+
);
315+
316+
assert.equal(secondThread.session?.providerThreadId,originalProviderThreadId);
317+
}),
318+
),
319+
);
320+
219321
it.live("runs multi-turn file edits and persists checkpoint diffs",()=>
220322
withHarness((harness)=>
221323
Effect.gen(function*(){

‎apps/server/integration/providerService.integration.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ it.effect("replays typed runtime fixture events", () =>
122122
{
123123
provider: "codex",
124124
cwd: fixture.cwd,
125+
runtimeMode: "full-access",
125126
},
126127
);
127128
assert.equal((session.threadId??"").length>0,true);
@@ -155,6 +156,7 @@ it.effect("replays file-changing fixture turn events", () =>
155156
{
156157
provider: "codex",
157158
cwd: fixture.cwd,
159+
runtimeMode: "full-access",
158160
},
159161
);
160162
assert.equal((session.threadId??"").length>0,true);
@@ -192,6 +194,7 @@ it.effect("runs multi-turn tool/approval flow", () =>
192194
{
193195
provider: "codex",
194196
cwd: fixture.cwd,
197+
runtimeMode: "full-access",
195198
},
196199
);
197200
assert.equal((session.threadId??"").length>0,true);
@@ -244,6 +247,7 @@ it.effect("rolls back provider conversation state only", () =>
244247
{
245248
provider: "codex",
246249
cwd: fixture.cwd,
250+
runtimeMode: "full-access",
247251
},
248252
);
249253
assert.equal((session.threadId??"").length>0,true);

‎apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ function makeSnapshot(input: {
4343
projectId: input.projectId,
4444
title: "Thread",
4545
model: "gpt-5-codex",
46+
runtimeMode: "full-access",
4647
branch: null,
4748
worktreePath: input.worktreePath,
4849
latestTurn: {

‎apps/server/src/codexAppServerManager.test.ts‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import{describe,expect,it,vi}from"vitest";
2+
import{randomUUID}from"node:crypto";
3+
import{mkdtempSync,rmSync,writeFileSync}from"node:fs";
4+
importosfrom"node:os";
5+
importpathfrom"node:path";
26
import{ProviderSessionId}from"@t3tools/contracts";
37

48
import{
@@ -165,6 +169,7 @@ describe("startSession", () => {
165169
awaitexpect(
166170
manager.startSession({
167171
provider: "codex",
172+
runtimeMode: "full-access",
168173
}),
169174
).rejects.toThrow("cwd missing");
170175
expect(events).toHaveLength(1);
@@ -347,3 +352,84 @@ describe("thread checkpoint control", () => {
347352
});
348353
});
349354
});
355+
356+
describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume",()=>{
357+
it(
358+
"keeps prior thread history when resuming with a changed runtime mode",
359+
async()=>{
360+
constworkspaceDir=mkdtempSync(path.join(os.tmpdir(),"codex-live-resume-"));
361+
writeFileSync(path.join(workspaceDir,"README.md"),"hello\n","utf8");
362+
363+
constmanager=newCodexAppServerManager();
364+
365+
try{
366+
constfirstSession=awaitmanager.startSession({
367+
provider: "codex",
368+
cwd: workspaceDir,
369+
runtimeMode: "full-access",
370+
providerOptions: {
371+
codex: {
372+
binaryPath: process.env.CODEX_BINARY_PATH,
373+
...(process.env.CODEX_HOME_PATH
374+
? {homePath: process.env.CODEX_HOME_PATH}
375+
: {}),
376+
},
377+
},
378+
});
379+
380+
constfirstTurn=awaitmanager.sendTurn({
381+
sessionId: firstSession.sessionId,
382+
input: `Reply with exactly the word ALPHA ${randomUUID()}`,
383+
});
384+
385+
expect(firstTurn.threadId).toBe(firstSession.threadId);
386+
387+
awaitvi.waitFor(async()=>{
388+
constsnapshot=awaitmanager.readThread(firstSession.sessionId);
389+
expect(snapshot.turns.length).toBeGreaterThan(0);
390+
},{timeout: 120_000,interval: 1_000});
391+
392+
constfirstSnapshot=awaitmanager.readThread(firstSession.sessionId);
393+
constoriginalThreadId=firstSnapshot.threadId;
394+
constoriginalTurnCount=firstSnapshot.turns.length;
395+
396+
manager.stopSession(firstSession.sessionId);
397+
398+
constresumedSession=awaitmanager.startSession({
399+
provider: "codex",
400+
cwd: workspaceDir,
401+
runtimeMode: "approval-required",
402+
resumeCursor: firstSession.resumeCursor,
403+
providerOptions: {
404+
codex: {
405+
binaryPath: process.env.CODEX_BINARY_PATH,
406+
...(process.env.CODEX_HOME_PATH
407+
? {homePath: process.env.CODEX_HOME_PATH}
408+
: {}),
409+
},
410+
},
411+
});
412+
413+
expect(resumedSession.threadId).toBe(originalThreadId);
414+
415+
constresumedSnapshotBeforeTurn=awaitmanager.readThread(resumedSession.sessionId);
416+
expect(resumedSnapshotBeforeTurn.threadId).toBe(originalThreadId);
417+
expect(resumedSnapshotBeforeTurn.turns.length).toBeGreaterThanOrEqual(originalTurnCount);
418+
419+
awaitmanager.sendTurn({
420+
sessionId: resumedSession.sessionId,
421+
input: `Reply with exactly the word BETA ${randomUUID()}`,
422+
});
423+
424+
awaitvi.waitFor(async()=>{
425+
constsnapshot=awaitmanager.readThread(resumedSession.sessionId);
426+
expect(snapshot.turns.length).toBeGreaterThan(originalTurnCount);
427+
},{timeout: 120_000,interval: 1_000});
428+
}finally{
429+
manager.stopAll();
430+
rmSync(workspaceDir,{recursive: true,force: true});
431+
}
432+
},
433+
180_000,
434+
);
435+
});

‎apps/server/src/codexAppServerManager.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
204204
sessionId,
205205
provider: "codex",
206206
status: "connecting",
207+
runtimeMode: input.runtimeMode,
207208
model: normalizeCodexModelSlug(input.model),
208209
cwd: resolvedCwd,
209210
createdAt: now,

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 689dda5

Browse files
Persist thread runtime mode and handle mode-switch session restarts
- Add `thread.runtime-mode.set` -> `thread.runtime-mode-set` flow in decider/reactors/projectors - Persist runtime mode on thread/session projections with new DB migrations - Update provider and Codex integration tests to verify thread continuity across mode changes Co-authored-by: codex <codex@users.noreply.github.com>
1 parent 0a67f9d commit 689dda5

51 files changed

Lines changed: 743 additions & 173 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/server/integration/OrchestrationEngineHarness.integration.ts‎

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ import { ProviderUnsupportedError } from "../src/provider/Errors.ts";
3737
import{ProviderAdapterRegistry}from"../src/provider/Services/ProviderAdapterRegistry.ts";
3838
import{ProviderSessionDirectoryLive}from"../src/provider/Layers/ProviderSessionDirectory.ts";
3939
import{makeProviderServiceLive}from"../src/provider/Layers/ProviderService.ts";
40+
import{makeCodexAdapterLive}from"../src/provider/Layers/CodexAdapter.ts";
41+
import{CodexAdapter}from"../src/provider/Services/CodexAdapter.ts";
4042
import{ProviderService}from"../src/provider/Services/ProviderService.ts";
4143
import{CheckpointReactorLive}from"../src/orchestration/Layers/CheckpointReactor.ts";
4244
import{OrchestrationEngineLive}from"../src/orchestration/Layers/OrchestrationEngine.ts";
@@ -187,6 +189,7 @@ export interface OrchestrationIntegrationHarness {
187189

188190
interfaceMakeOrchestrationIntegrationHarnessOptions{
189191
readonlyprovider?: "codex"|"claudeCode";
192+
readonlyrealCodex?: boolean;
190193
}
191194

192195
exportconstmakeOrchestrationIntegrationHarness=(
@@ -195,10 +198,21 @@ export const makeOrchestrationIntegrationHarness = (
195198
Effect.gen(function*(){
196199
constsleep=(ms: number)=>Effect.sleep(ms);
197200
constprovider=options?.provider??"codex";
198-
constadapterHarness=yield*makeTestProviderAdapterHarness({
199-
provider,
200-
});
201-
201+
constuseRealCodex=options?.realCodex===true;
202+
constadapterHarness=useRealCodex
203+
? null
204+
: yield*makeTestProviderAdapterHarness({
205+
provider,
206+
});
207+
constfakeRegistry=adapterHarness
208+
? Layer.succeed(ProviderAdapterRegistry,{
209+
getByProvider: (resolvedProvider)=>
210+
resolvedProvider===adapterHarness.provider
211+
? Effect.succeed(adapterHarness.adapter)
212+
: Effect.fail(newProviderUnsupportedError({provider: resolvedProvider})),
213+
listProviders: ()=>Effect.succeed([adapterHarness.provider]),
214+
}astypeofProviderAdapterRegistry.Service)
215+
: null;
202216
constrootDir=fs.mkdtempSync(path.join(os.tmpdir(),"t3-orchestration-integration-"));
203217
constworkspaceDir=path.join(rootDir,"workspace");
204218
conststateDir=path.join(rootDir,"state");
@@ -207,14 +221,6 @@ export const makeOrchestrationIntegrationHarness = (
207221
fs.mkdirSync(stateDir,{recursive: true});
208222
initializeGitWorkspace(workspaceDir);
209223

210-
constregistry: typeofProviderAdapterRegistry.Service={
211-
getByProvider: (provider)=>
212-
provider===adapterHarness.provider
213-
? Effect.succeed(adapterHarness.adapter)
214-
: Effect.fail(newProviderUnsupportedError({ provider })),
215-
listProviders: ()=>Effect.succeed([adapterHarness.provider]),
216-
};
217-
218224
constpersistenceLayer=makeSqlitePersistenceLive(dbPath);
219225
constorchestrationLayer=OrchestrationEngineLive.pipe(
220226
Layer.provide(OrchestrationProjectionPipelineLive),
@@ -224,10 +230,33 @@ export const makeOrchestrationIntegrationHarness = (
224230
constproviderSessionDirectoryLayer=ProviderSessionDirectoryLive.pipe(
225231
Layer.provide(ProviderSessionRuntimeRepositoryLive),
226232
);
227-
constproviderLayer=makeProviderServiceLive().pipe(
228-
Layer.provide(providerSessionDirectoryLayer),
229-
Layer.provide(Layer.succeed(ProviderAdapterRegistry,registry)),
233+
constrealCodexRegistry=Layer.effect(
234+
ProviderAdapterRegistry,
235+
Effect.gen(function*(){
236+
constcodexAdapter=yield*CodexAdapter;
237+
return{
238+
getByProvider: (resolvedProvider)=>
239+
resolvedProvider==="codex"
240+
? Effect.succeed(codexAdapter)
241+
: Effect.fail(newProviderUnsupportedError({provider: resolvedProvider})),
242+
listProviders: ()=>Effect.succeed(["codex"]asconst),
243+
}astypeofProviderAdapterRegistry.Service;
244+
}),
245+
).pipe(
246+
Layer.provide(makeCodexAdapterLive()),
247+
Layer.provideMerge(ServerConfig.layerTest(workspaceDir,stateDir)),
248+
Layer.provideMerge(NodeServices.layer),
249+
Layer.provideMerge(providerSessionDirectoryLayer),
230250
);
251+
constproviderLayer=useRealCodex
252+
? makeProviderServiceLive().pipe(
253+
Layer.provide(providerSessionDirectoryLayer),
254+
Layer.provide(realCodexRegistry),
255+
)
256+
: makeProviderServiceLive().pipe(
257+
Layer.provide(providerSessionDirectoryLayer),
258+
Layer.provide(fakeRegistry!),
259+
);
231260

232261
construntimeServicesLayer=Layer.mergeAll(
233262
orchestrationLayer,
@@ -407,8 +436,8 @@ export const makeOrchestrationIntegrationHarness = (
407436
return{
408437
rootDir,
409438
workspaceDir,
410-
dbPath,
411-
adapterHarness,
439+
dbPath,
440+
adapterHarness: adapterHarnessasTestProviderAdapterHarness,
412441
engine,
413442
snapshotQuery,
414443
providerService,

‎apps/server/integration/TestProviderAdapter.integration.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
264264
sessionId,
265265
provider,
266266
status: "ready",
267+
runtimeMode: input.runtimeMode,
267268
threadId,
268269
cwd: input.cwd,
269270
resumeCursor: input.resumeCursor??{ sessionId },

‎apps/server/integration/orchestrationEngine.integration.test.ts‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,16 @@ function withHarness<A, E>(
9797
);
9898
}
9999

100+
functionwithRealCodexHarness<A,E>(
101+
use: (harness: OrchestrationIntegrationHarness)=>Effect.Effect<A,E>,
102+
){
103+
returnEffect.acquireUseRelease(
104+
makeOrchestrationIntegrationHarness({provider: "codex",realCodex: true}),
105+
use,
106+
(harness)=>harness.dispose,
107+
);
108+
}
109+
100110
constseedProjectAndThread=(harness: OrchestrationIntegrationHarness)=>
101111
Effect.gen(function*(){
102112
constcreatedAt=nowIso();
@@ -118,6 +128,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>
118128
projectId: PROJECT_ID,
119129
title: "Integration Thread",
120130
model: "gpt-5-codex",
131+
runtimeMode: "approval-required",
121132
branch: null,
122133
worktreePath: harness.workspaceDir,
123134
createdAt,
@@ -216,6 +227,97 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite +
216227
),
217228
);
218229

230+
it.live.skipIf(!process.env.CODEX_BINARY_PATH)(
231+
"keeps the same Codex provider thread across runtime mode switches",
232+
()=>
233+
withRealCodexHarness((harness)=>
234+
Effect.gen(function*(){
235+
constcreatedAt=nowIso();
236+
237+
yield*harness.engine.dispatch({
238+
type: "project.create",
239+
commandId: CommandId.makeUnsafe("cmd-project-create-real-codex"),
240+
projectId: PROJECT_ID,
241+
title: "Integration Project",
242+
workspaceRoot: harness.workspaceDir,
243+
defaultModel: "gpt-5.3-codex",
244+
createdAt,
245+
});
246+
247+
yield*harness.engine.dispatch({
248+
type: "thread.create",
249+
commandId: CommandId.makeUnsafe("cmd-thread-create-real-codex"),
250+
threadId: THREAD_ID,
251+
projectId: PROJECT_ID,
252+
title: "Integration Thread",
253+
model: "gpt-5.3-codex",
254+
runtimeMode: "full-access",
255+
branch: null,
256+
worktreePath: harness.workspaceDir,
257+
createdAt,
258+
});
259+
260+
yield*harness.engine.dispatch({
261+
type: "thread.turn.start",
262+
commandId: CommandId.makeUnsafe("cmd-turn-start-real-codex-1"),
263+
threadId: THREAD_ID,
264+
message: {
265+
messageId: asMessageId("msg-real-codex-1"),
266+
role: "user",
267+
text: "Reply with exactly ALPHA.",
268+
attachments: [],
269+
},
270+
runtimeMode: "full-access",
271+
createdAt: nowIso(),
272+
});
273+
274+
constfirstThread=yield*harness.waitForThread(
275+
THREAD_ID,
276+
(entry)=>
277+
entry.session?.status==="ready"&&
278+
entry.session.providerName==="codex"&&
279+
entry.session.providerThreadId!==null&&
280+
entry.messages.some(
281+
(message)=>message.role==="assistant"&&message.streaming===false,
282+
),
283+
180_000,
284+
);
285+
286+
constoriginalProviderThreadId=firstThread.session?.providerThreadId;
287+
assert.isNotNull(originalProviderThreadId);
288+
289+
yield*harness.engine.dispatch({
290+
type: "thread.turn.start",
291+
commandId: CommandId.makeUnsafe("cmd-turn-start-real-codex-2"),
292+
threadId: THREAD_ID,
293+
message: {
294+
messageId: asMessageId("msg-real-codex-2"),
295+
role: "user",
296+
text: "Reply with exactly BETA.",
297+
attachments: [],
298+
},
299+
runtimeMode: "approval-required",
300+
createdAt: nowIso(),
301+
});
302+
303+
constsecondThread=yield*harness.waitForThread(
304+
THREAD_ID,
305+
(entry)=>
306+
entry.session?.status==="ready"&&
307+
entry.session.providerName==="codex"&&
308+
entry.session.providerThreadId!==null&&
309+
entry.session.runtimeMode==="approval-required"&&
310+
entry.messages.some(
311+
(message)=>message.role==="assistant"&&message.text.includes("BETA"),
312+
),
313+
180_000,
314+
);
315+
316+
assert.equal(secondThread.session?.providerThreadId,originalProviderThreadId);
317+
}),
318+
),
319+
);
320+
219321
it.live("runs multi-turn file edits and persists checkpoint diffs",()=>
220322
withHarness((harness)=>
221323
Effect.gen(function*(){

‎apps/server/integration/providerService.integration.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ it.effect("replays typed runtime fixture events", () =>
122122
{
123123
provider: "codex",
124124
cwd: fixture.cwd,
125+
runtimeMode: "full-access",
125126
},
126127
);
127128
assert.equal((session.threadId??"").length>0,true);
@@ -155,6 +156,7 @@ it.effect("replays file-changing fixture turn events", () =>
155156
{
156157
provider: "codex",
157158
cwd: fixture.cwd,
159+
runtimeMode: "full-access",
158160
},
159161
);
160162
assert.equal((session.threadId??"").length>0,true);
@@ -192,6 +194,7 @@ it.effect("runs multi-turn tool/approval flow", () =>
192194
{
193195
provider: "codex",
194196
cwd: fixture.cwd,
197+
runtimeMode: "full-access",
195198
},
196199
);
197200
assert.equal((session.threadId??"").length>0,true);
@@ -244,6 +247,7 @@ it.effect("rolls back provider conversation state only", () =>
244247
{
245248
provider: "codex",
246249
cwd: fixture.cwd,
250+
runtimeMode: "full-access",
247251
},
248252
);
249253
assert.equal((session.threadId??"").length>0,true);

‎apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ function makeSnapshot(input: {
4343
projectId: input.projectId,
4444
title: "Thread",
4545
model: "gpt-5-codex",
46+
runtimeMode: "full-access",
4647
branch: null,
4748
worktreePath: input.worktreePath,
4849
latestTurn: {

‎apps/server/src/codexAppServerManager.test.ts‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import{describe,expect,it,vi}from"vitest";
2+
import{randomUUID}from"node:crypto";
3+
import{mkdtempSync,rmSync,writeFileSync}from"node:fs";
4+
importosfrom"node:os";
5+
importpathfrom"node:path";
26
import{ProviderSessionId}from"@t3tools/contracts";
37

48
import{
@@ -165,6 +169,7 @@ describe("startSession", () => {
165169
awaitexpect(
166170
manager.startSession({
167171
provider: "codex",
172+
runtimeMode: "full-access",
168173
}),
169174
).rejects.toThrow("cwd missing");
170175
expect(events).toHaveLength(1);
@@ -347,3 +352,84 @@ describe("thread checkpoint control", () => {
347352
});
348353
});
349354
});
355+
356+
describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume",()=>{
357+
it(
358+
"keeps prior thread history when resuming with a changed runtime mode",
359+
async()=>{
360+
constworkspaceDir=mkdtempSync(path.join(os.tmpdir(),"codex-live-resume-"));
361+
writeFileSync(path.join(workspaceDir,"README.md"),"hello\n","utf8");
362+
363+
constmanager=newCodexAppServerManager();
364+
365+
try{
366+
constfirstSession=awaitmanager.startSession({
367+
provider: "codex",
368+
cwd: workspaceDir,
369+
runtimeMode: "full-access",
370+
providerOptions: {
371+
codex: {
372+
binaryPath: process.env.CODEX_BINARY_PATH,
373+
...(process.env.CODEX_HOME_PATH
374+
? {homePath: process.env.CODEX_HOME_PATH}
375+
: {}),
376+
},
377+
},
378+
});
379+
380+
constfirstTurn=awaitmanager.sendTurn({
381+
sessionId: firstSession.sessionId,
382+
input: `Reply with exactly the word ALPHA ${randomUUID()}`,
383+
});
384+
385+
expect(firstTurn.threadId).toBe(firstSession.threadId);
386+
387+
awaitvi.waitFor(async()=>{
388+
constsnapshot=awaitmanager.readThread(firstSession.sessionId);
389+
expect(snapshot.turns.length).toBeGreaterThan(0);
390+
},{timeout: 120_000,interval: 1_000});
391+
392+
constfirstSnapshot=awaitmanager.readThread(firstSession.sessionId);
393+
constoriginalThreadId=firstSnapshot.threadId;
394+
constoriginalTurnCount=firstSnapshot.turns.length;
395+
396+
manager.stopSession(firstSession.sessionId);
397+
398+
constresumedSession=awaitmanager.startSession({
399+
provider: "codex",
400+
cwd: workspaceDir,
401+
runtimeMode: "approval-required",
402+
resumeCursor: firstSession.resumeCursor,
403+
providerOptions: {
404+
codex: {
405+
binaryPath: process.env.CODEX_BINARY_PATH,
406+
...(process.env.CODEX_HOME_PATH
407+
? {homePath: process.env.CODEX_HOME_PATH}
408+
: {}),
409+
},
410+
},
411+
});
412+
413+
expect(resumedSession.threadId).toBe(originalThreadId);
414+
415+
constresumedSnapshotBeforeTurn=awaitmanager.readThread(resumedSession.sessionId);
416+
expect(resumedSnapshotBeforeTurn.threadId).toBe(originalThreadId);
417+
expect(resumedSnapshotBeforeTurn.turns.length).toBeGreaterThanOrEqual(originalTurnCount);
418+
419+
awaitmanager.sendTurn({
420+
sessionId: resumedSession.sessionId,
421+
input: `Reply with exactly the word BETA ${randomUUID()}`,
422+
});
423+
424+
awaitvi.waitFor(async()=>{
425+
constsnapshot=awaitmanager.readThread(resumedSession.sessionId);
426+
expect(snapshot.turns.length).toBeGreaterThan(originalTurnCount);
427+
},{timeout: 120_000,interval: 1_000});
428+
}finally{
429+
manager.stopAll();
430+
rmSync(workspaceDir,{recursive: true,force: true});
431+
}
432+
},
433+
180_000,
434+
);
435+
});

‎apps/server/src/codexAppServerManager.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
204204
sessionId,
205205
provider: "codex",
206206
status: "connecting",
207+
runtimeMode: input.runtimeMode,
207208
model: normalizeCodexModelSlug(input.model),
208209
cwd: resolvedCwd,
209210
createdAt: now,

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 689dda5

Browse files
Persist thread runtime mode and handle mode-switch session restarts
- Add `thread.runtime-mode.set` -> `thread.runtime-mode-set` flow in decider/reactors/projectors - Persist runtime mode on thread/session projections with new DB migrations - Update provider and Codex integration tests to verify thread continuity across mode changes Co-authored-by: codex <codex@users.noreply.github.com>
1 parent 0a67f9d commit 689dda5

51 files changed

Lines changed: 743 additions & 173 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/server/integration/OrchestrationEngineHarness.integration.ts‎

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ import { ProviderUnsupportedError } from "../src/provider/Errors.ts";
3737
import{ProviderAdapterRegistry}from"../src/provider/Services/ProviderAdapterRegistry.ts";
3838
import{ProviderSessionDirectoryLive}from"../src/provider/Layers/ProviderSessionDirectory.ts";
3939
import{makeProviderServiceLive}from"../src/provider/Layers/ProviderService.ts";
40+
import{makeCodexAdapterLive}from"../src/provider/Layers/CodexAdapter.ts";
41+
import{CodexAdapter}from"../src/provider/Services/CodexAdapter.ts";
4042
import{ProviderService}from"../src/provider/Services/ProviderService.ts";
4143
import{CheckpointReactorLive}from"../src/orchestration/Layers/CheckpointReactor.ts";
4244
import{OrchestrationEngineLive}from"../src/orchestration/Layers/OrchestrationEngine.ts";
@@ -187,6 +189,7 @@ export interface OrchestrationIntegrationHarness {
187189

188190
interfaceMakeOrchestrationIntegrationHarnessOptions{
189191
readonlyprovider?: "codex"|"claudeCode";
192+
readonlyrealCodex?: boolean;
190193
}
191194

192195
exportconstmakeOrchestrationIntegrationHarness=(
@@ -195,10 +198,21 @@ export const makeOrchestrationIntegrationHarness = (
195198
Effect.gen(function*(){
196199
constsleep=(ms: number)=>Effect.sleep(ms);
197200
constprovider=options?.provider??"codex";
198-
constadapterHarness=yield*makeTestProviderAdapterHarness({
199-
provider,
200-
});
201-
201+
constuseRealCodex=options?.realCodex===true;
202+
constadapterHarness=useRealCodex
203+
? null
204+
: yield*makeTestProviderAdapterHarness({
205+
provider,
206+
});
207+
constfakeRegistry=adapterHarness
208+
? Layer.succeed(ProviderAdapterRegistry,{
209+
getByProvider: (resolvedProvider)=>
210+
resolvedProvider===adapterHarness.provider
211+
? Effect.succeed(adapterHarness.adapter)
212+
: Effect.fail(newProviderUnsupportedError({provider: resolvedProvider})),
213+
listProviders: ()=>Effect.succeed([adapterHarness.provider]),
214+
}astypeofProviderAdapterRegistry.Service)
215+
: null;
202216
constrootDir=fs.mkdtempSync(path.join(os.tmpdir(),"t3-orchestration-integration-"));
203217
constworkspaceDir=path.join(rootDir,"workspace");
204218
conststateDir=path.join(rootDir,"state");
@@ -207,14 +221,6 @@ export const makeOrchestrationIntegrationHarness = (
207221
fs.mkdirSync(stateDir,{recursive: true});
208222
initializeGitWorkspace(workspaceDir);
209223

210-
constregistry: typeofProviderAdapterRegistry.Service={
211-
getByProvider: (provider)=>
212-
provider===adapterHarness.provider
213-
? Effect.succeed(adapterHarness.adapter)
214-
: Effect.fail(newProviderUnsupportedError({ provider })),
215-
listProviders: ()=>Effect.succeed([adapterHarness.provider]),
216-
};
217-
218224
constpersistenceLayer=makeSqlitePersistenceLive(dbPath);
219225
constorchestrationLayer=OrchestrationEngineLive.pipe(
220226
Layer.provide(OrchestrationProjectionPipelineLive),
@@ -224,10 +230,33 @@ export const makeOrchestrationIntegrationHarness = (
224230
constproviderSessionDirectoryLayer=ProviderSessionDirectoryLive.pipe(
225231
Layer.provide(ProviderSessionRuntimeRepositoryLive),
226232
);
227-
constproviderLayer=makeProviderServiceLive().pipe(
228-
Layer.provide(providerSessionDirectoryLayer),
229-
Layer.provide(Layer.succeed(ProviderAdapterRegistry,registry)),
233+
constrealCodexRegistry=Layer.effect(
234+
ProviderAdapterRegistry,
235+
Effect.gen(function*(){
236+
constcodexAdapter=yield*CodexAdapter;
237+
return{
238+
getByProvider: (resolvedProvider)=>
239+
resolvedProvider==="codex"
240+
? Effect.succeed(codexAdapter)
241+
: Effect.fail(newProviderUnsupportedError({provider: resolvedProvider})),
242+
listProviders: ()=>Effect.succeed(["codex"]asconst),
243+
}astypeofProviderAdapterRegistry.Service;
244+
}),
245+
).pipe(
246+
Layer.provide(makeCodexAdapterLive()),
247+
Layer.provideMerge(ServerConfig.layerTest(workspaceDir,stateDir)),
248+
Layer.provideMerge(NodeServices.layer),
249+
Layer.provideMerge(providerSessionDirectoryLayer),
230250
);
251+
constproviderLayer=useRealCodex
252+
? makeProviderServiceLive().pipe(
253+
Layer.provide(providerSessionDirectoryLayer),
254+
Layer.provide(realCodexRegistry),
255+
)
256+
: makeProviderServiceLive().pipe(
257+
Layer.provide(providerSessionDirectoryLayer),
258+
Layer.provide(fakeRegistry!),
259+
);
231260

232261
construntimeServicesLayer=Layer.mergeAll(
233262
orchestrationLayer,
@@ -407,8 +436,8 @@ export const makeOrchestrationIntegrationHarness = (
407436
return{
408437
rootDir,
409438
workspaceDir,
410-
dbPath,
411-
adapterHarness,
439+
dbPath,
440+
adapterHarness: adapterHarnessasTestProviderAdapterHarness,
412441
engine,
413442
snapshotQuery,
414443
providerService,

‎apps/server/integration/TestProviderAdapter.integration.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
264264
sessionId,
265265
provider,
266266
status: "ready",
267+
runtimeMode: input.runtimeMode,
267268
threadId,
268269
cwd: input.cwd,
269270
resumeCursor: input.resumeCursor??{ sessionId },

‎apps/server/integration/orchestrationEngine.integration.test.ts‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,16 @@ function withHarness<A, E>(
9797
);
9898
}
9999

100+
functionwithRealCodexHarness<A,E>(
101+
use: (harness: OrchestrationIntegrationHarness)=>Effect.Effect<A,E>,
102+
){
103+
returnEffect.acquireUseRelease(
104+
makeOrchestrationIntegrationHarness({provider: "codex",realCodex: true}),
105+
use,
106+
(harness)=>harness.dispose,
107+
);
108+
}
109+
100110
constseedProjectAndThread=(harness: OrchestrationIntegrationHarness)=>
101111
Effect.gen(function*(){
102112
constcreatedAt=nowIso();
@@ -118,6 +128,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>
118128
projectId: PROJECT_ID,
119129
title: "Integration Thread",
120130
model: "gpt-5-codex",
131+
runtimeMode: "approval-required",
121132
branch: null,
122133
worktreePath: harness.workspaceDir,
123134
createdAt,
@@ -216,6 +227,97 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite +
216227
),
217228
);
218229

230+
it.live.skipIf(!process.env.CODEX_BINARY_PATH)(
231+
"keeps the same Codex provider thread across runtime mode switches",
232+
()=>
233+
withRealCodexHarness((harness)=>
234+
Effect.gen(function*(){
235+
constcreatedAt=nowIso();
236+
237+
yield*harness.engine.dispatch({
238+
type: "project.create",
239+
commandId: CommandId.makeUnsafe("cmd-project-create-real-codex"),
240+
projectId: PROJECT_ID,
241+
title: "Integration Project",
242+
workspaceRoot: harness.workspaceDir,
243+
defaultModel: "gpt-5.3-codex",
244+
createdAt,
245+
});
246+
247+
yield*harness.engine.dispatch({
248+
type: "thread.create",
249+
commandId: CommandId.makeUnsafe("cmd-thread-create-real-codex"),
250+
threadId: THREAD_ID,
251+
projectId: PROJECT_ID,
252+
title: "Integration Thread",
253+
model: "gpt-5.3-codex",
254+
runtimeMode: "full-access",
255+
branch: null,
256+
worktreePath: harness.workspaceDir,
257+
createdAt,
258+
});
259+
260+
yield*harness.engine.dispatch({
261+
type: "thread.turn.start",
262+
commandId: CommandId.makeUnsafe("cmd-turn-start-real-codex-1"),
263+
threadId: THREAD_ID,
264+
message: {
265+
messageId: asMessageId("msg-real-codex-1"),
266+
role: "user",
267+
text: "Reply with exactly ALPHA.",
268+
attachments: [],
269+
},
270+
runtimeMode: "full-access",
271+
createdAt: nowIso(),
272+
});
273+
274+
constfirstThread=yield*harness.waitForThread(
275+
THREAD_ID,
276+
(entry)=>
277+
entry.session?.status==="ready"&&
278+
entry.session.providerName==="codex"&&
279+
entry.session.providerThreadId!==null&&
280+
entry.messages.some(
281+
(message)=>message.role==="assistant"&&message.streaming===false,
282+
),
283+
180_000,
284+
);
285+
286+
constoriginalProviderThreadId=firstThread.session?.providerThreadId;
287+
assert.isNotNull(originalProviderThreadId);
288+
289+
yield*harness.engine.dispatch({
290+
type: "thread.turn.start",
291+
commandId: CommandId.makeUnsafe("cmd-turn-start-real-codex-2"),
292+
threadId: THREAD_ID,
293+
message: {
294+
messageId: asMessageId("msg-real-codex-2"),
295+
role: "user",
296+
text: "Reply with exactly BETA.",
297+
attachments: [],
298+
},
299+
runtimeMode: "approval-required",
300+
createdAt: nowIso(),
301+
});
302+
303+
constsecondThread=yield*harness.waitForThread(
304+
THREAD_ID,
305+
(entry)=>
306+
entry.session?.status==="ready"&&
307+
entry.session.providerName==="codex"&&
308+
entry.session.providerThreadId!==null&&
309+
entry.session.runtimeMode==="approval-required"&&
310+
entry.messages.some(
311+
(message)=>message.role==="assistant"&&message.text.includes("BETA"),
312+
),
313+
180_000,
314+
);
315+
316+
assert.equal(secondThread.session?.providerThreadId,originalProviderThreadId);
317+
}),
318+
),
319+
);
320+
219321
it.live("runs multi-turn file edits and persists checkpoint diffs",()=>
220322
withHarness((harness)=>
221323
Effect.gen(function*(){

‎apps/server/integration/providerService.integration.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ it.effect("replays typed runtime fixture events", () =>
122122
{
123123
provider: "codex",
124124
cwd: fixture.cwd,
125+
runtimeMode: "full-access",
125126
},
126127
);
127128
assert.equal((session.threadId??"").length>0,true);
@@ -155,6 +156,7 @@ it.effect("replays file-changing fixture turn events", () =>
155156
{
156157
provider: "codex",
157158
cwd: fixture.cwd,
159+
runtimeMode: "full-access",
158160
},
159161
);
160162
assert.equal((session.threadId??"").length>0,true);
@@ -192,6 +194,7 @@ it.effect("runs multi-turn tool/approval flow", () =>
192194
{
193195
provider: "codex",
194196
cwd: fixture.cwd,
197+
runtimeMode: "full-access",
195198
},
196199
);
197200
assert.equal((session.threadId??"").length>0,true);
@@ -244,6 +247,7 @@ it.effect("rolls back provider conversation state only", () =>
244247
{
245248
provider: "codex",
246249
cwd: fixture.cwd,
250+
runtimeMode: "full-access",
247251
},
248252
);
249253
assert.equal((session.threadId??"").length>0,true);

‎apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ function makeSnapshot(input: {
4343
projectId: input.projectId,
4444
title: "Thread",
4545
model: "gpt-5-codex",
46+
runtimeMode: "full-access",
4647
branch: null,
4748
worktreePath: input.worktreePath,
4849
latestTurn: {

‎apps/server/src/codexAppServerManager.test.ts‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import{describe,expect,it,vi}from"vitest";
2+
import{randomUUID}from"node:crypto";
3+
import{mkdtempSync,rmSync,writeFileSync}from"node:fs";
4+
importosfrom"node:os";
5+
importpathfrom"node:path";
26
import{ProviderSessionId}from"@t3tools/contracts";
37

48
import{
@@ -165,6 +169,7 @@ describe("startSession", () => {
165169
awaitexpect(
166170
manager.startSession({
167171
provider: "codex",
172+
runtimeMode: "full-access",
168173
}),
169174
).rejects.toThrow("cwd missing");
170175
expect(events).toHaveLength(1);
@@ -347,3 +352,84 @@ describe("thread checkpoint control", () => {
347352
});
348353
});
349354
});
355+
356+
describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume",()=>{
357+
it(
358+
"keeps prior thread history when resuming with a changed runtime mode",
359+
async()=>{
360+
constworkspaceDir=mkdtempSync(path.join(os.tmpdir(),"codex-live-resume-"));
361+
writeFileSync(path.join(workspaceDir,"README.md"),"hello\n","utf8");
362+
363+
constmanager=newCodexAppServerManager();
364+
365+
try{
366+
constfirstSession=awaitmanager.startSession({
367+
provider: "codex",
368+
cwd: workspaceDir,
369+
runtimeMode: "full-access",
370+
providerOptions: {
371+
codex: {
372+
binaryPath: process.env.CODEX_BINARY_PATH,
373+
...(process.env.CODEX_HOME_PATH
374+
? {homePath: process.env.CODEX_HOME_PATH}
375+
: {}),
376+
},
377+
},
378+
});
379+
380+
constfirstTurn=awaitmanager.sendTurn({
381+
sessionId: firstSession.sessionId,
382+
input: `Reply with exactly the word ALPHA ${randomUUID()}`,
383+
});
384+
385+
expect(firstTurn.threadId).toBe(firstSession.threadId);
386+
387+
awaitvi.waitFor(async()=>{
388+
constsnapshot=awaitmanager.readThread(firstSession.sessionId);
389+
expect(snapshot.turns.length).toBeGreaterThan(0);
390+
},{timeout: 120_000,interval: 1_000});
391+
392+
constfirstSnapshot=awaitmanager.readThread(firstSession.sessionId);
393+
constoriginalThreadId=firstSnapshot.threadId;
394+
constoriginalTurnCount=firstSnapshot.turns.length;
395+
396+
manager.stopSession(firstSession.sessionId);
397+
398+
constresumedSession=awaitmanager.startSession({
399+
provider: "codex",
400+
cwd: workspaceDir,
401+
runtimeMode: "approval-required",
402+
resumeCursor: firstSession.resumeCursor,
403+
providerOptions: {
404+
codex: {
405+
binaryPath: process.env.CODEX_BINARY_PATH,
406+
...(process.env.CODEX_HOME_PATH
407+
? {homePath: process.env.CODEX_HOME_PATH}
408+
: {}),
409+
},
410+
},
411+
});
412+
413+
expect(resumedSession.threadId).toBe(originalThreadId);
414+
415+
constresumedSnapshotBeforeTurn=awaitmanager.readThread(resumedSession.sessionId);
416+
expect(resumedSnapshotBeforeTurn.threadId).toBe(originalThreadId);
417+
expect(resumedSnapshotBeforeTurn.turns.length).toBeGreaterThanOrEqual(originalTurnCount);
418+
419+
awaitmanager.sendTurn({
420+
sessionId: resumedSession.sessionId,
421+
input: `Reply with exactly the word BETA ${randomUUID()}`,
422+
});
423+
424+
awaitvi.waitFor(async()=>{
425+
constsnapshot=awaitmanager.readThread(resumedSession.sessionId);
426+
expect(snapshot.turns.length).toBeGreaterThan(originalTurnCount);
427+
},{timeout: 120_000,interval: 1_000});
428+
}finally{
429+
manager.stopAll();
430+
rmSync(workspaceDir,{recursive: true,force: true});
431+
}
432+
},
433+
180_000,
434+
);
435+
});

‎apps/server/src/codexAppServerManager.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
204204
sessionId,
205205
provider: "codex",
206206
status: "connecting",
207+
runtimeMode: input.runtimeMode,
207208
model: normalizeCodexModelSlug(input.model),
208209
cwd: resolvedCwd,
209210
createdAt: now,

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Commit 689dda5

Browse files
Persist thread runtime mode and handle mode-switch session restarts
- Add `thread.runtime-mode.set` -> `thread.runtime-mode-set` flow in decider/reactors/projectors - Persist runtime mode on thread/session projections with new DB migrations - Update provider and Codex integration tests to verify thread continuity across mode changes Co-authored-by: codex <codex@users.noreply.github.com>
1 parent 0a67f9d commit 689dda5

51 files changed

Lines changed: 743 additions & 173 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/server/integration/OrchestrationEngineHarness.integration.ts‎

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ import { ProviderUnsupportedError } from "../src/provider/Errors.ts";
3737
import{ProviderAdapterRegistry}from"../src/provider/Services/ProviderAdapterRegistry.ts";
3838
import{ProviderSessionDirectoryLive}from"../src/provider/Layers/ProviderSessionDirectory.ts";
3939
import{makeProviderServiceLive}from"../src/provider/Layers/ProviderService.ts";
40+
import{makeCodexAdapterLive}from"../src/provider/Layers/CodexAdapter.ts";
41+
import{CodexAdapter}from"../src/provider/Services/CodexAdapter.ts";
4042
import{ProviderService}from"../src/provider/Services/ProviderService.ts";
4143
import{CheckpointReactorLive}from"../src/orchestration/Layers/CheckpointReactor.ts";
4244
import{OrchestrationEngineLive}from"../src/orchestration/Layers/OrchestrationEngine.ts";
@@ -187,6 +189,7 @@ export interface OrchestrationIntegrationHarness {
187189

188190
interfaceMakeOrchestrationIntegrationHarnessOptions{
189191
readonlyprovider?: "codex"|"claudeCode";
192+
readonlyrealCodex?: boolean;
190193
}
191194

192195
exportconstmakeOrchestrationIntegrationHarness=(
@@ -195,10 +198,21 @@ export const makeOrchestrationIntegrationHarness = (
195198
Effect.gen(function*(){
196199
constsleep=(ms: number)=>Effect.sleep(ms);
197200
constprovider=options?.provider??"codex";
198-
constadapterHarness=yield*makeTestProviderAdapterHarness({
199-
provider,
200-
});
201-
201+
constuseRealCodex=options?.realCodex===true;
202+
constadapterHarness=useRealCodex
203+
? null
204+
: yield*makeTestProviderAdapterHarness({
205+
provider,
206+
});
207+
constfakeRegistry=adapterHarness
208+
? Layer.succeed(ProviderAdapterRegistry,{
209+
getByProvider: (resolvedProvider)=>
210+
resolvedProvider===adapterHarness.provider
211+
? Effect.succeed(adapterHarness.adapter)
212+
: Effect.fail(newProviderUnsupportedError({provider: resolvedProvider})),
213+
listProviders: ()=>Effect.succeed([adapterHarness.provider]),
214+
}astypeofProviderAdapterRegistry.Service)
215+
: null;
202216
constrootDir=fs.mkdtempSync(path.join(os.tmpdir(),"t3-orchestration-integration-"));
203217
constworkspaceDir=path.join(rootDir,"workspace");
204218
conststateDir=path.join(rootDir,"state");
@@ -207,14 +221,6 @@ export const makeOrchestrationIntegrationHarness = (
207221
fs.mkdirSync(stateDir,{recursive: true});
208222
initializeGitWorkspace(workspaceDir);
209223

210-
constregistry: typeofProviderAdapterRegistry.Service={
211-
getByProvider: (provider)=>
212-
provider===adapterHarness.provider
213-
? Effect.succeed(adapterHarness.adapter)
214-
: Effect.fail(newProviderUnsupportedError({ provider })),
215-
listProviders: ()=>Effect.succeed([adapterHarness.provider]),
216-
};
217-
218224
constpersistenceLayer=makeSqlitePersistenceLive(dbPath);
219225
constorchestrationLayer=OrchestrationEngineLive.pipe(
220226
Layer.provide(OrchestrationProjectionPipelineLive),
@@ -224,10 +230,33 @@ export const makeOrchestrationIntegrationHarness = (
224230
constproviderSessionDirectoryLayer=ProviderSessionDirectoryLive.pipe(
225231
Layer.provide(ProviderSessionRuntimeRepositoryLive),
226232
);
227-
constproviderLayer=makeProviderServiceLive().pipe(
228-
Layer.provide(providerSessionDirectoryLayer),
229-
Layer.provide(Layer.succeed(ProviderAdapterRegistry,registry)),
233+
constrealCodexRegistry=Layer.effect(
234+
ProviderAdapterRegistry,
235+
Effect.gen(function*(){
236+
constcodexAdapter=yield*CodexAdapter;
237+
return{
238+
getByProvider: (resolvedProvider)=>
239+
resolvedProvider==="codex"
240+
? Effect.succeed(codexAdapter)
241+
: Effect.fail(newProviderUnsupportedError({provider: resolvedProvider})),
242+
listProviders: ()=>Effect.succeed(["codex"]asconst),
243+
}astypeofProviderAdapterRegistry.Service;
244+
}),
245+
).pipe(
246+
Layer.provide(makeCodexAdapterLive()),
247+
Layer.provideMerge(ServerConfig.layerTest(workspaceDir,stateDir)),
248+
Layer.provideMerge(NodeServices.layer),
249+
Layer.provideMerge(providerSessionDirectoryLayer),
230250
);
251+
constproviderLayer=useRealCodex
252+
? makeProviderServiceLive().pipe(
253+
Layer.provide(providerSessionDirectoryLayer),
254+
Layer.provide(realCodexRegistry),
255+
)
256+
: makeProviderServiceLive().pipe(
257+
Layer.provide(providerSessionDirectoryLayer),
258+
Layer.provide(fakeRegistry!),
259+
);
231260

232261
construntimeServicesLayer=Layer.mergeAll(
233262
orchestrationLayer,
@@ -407,8 +436,8 @@ export const makeOrchestrationIntegrationHarness = (
407436
return{
408437
rootDir,
409438
workspaceDir,
410-
dbPath,
411-
adapterHarness,
439+
dbPath,
440+
adapterHarness: adapterHarnessasTestProviderAdapterHarness,
412441
engine,
413442
snapshotQuery,
414443
providerService,

‎apps/server/integration/TestProviderAdapter.integration.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
264264
sessionId,
265265
provider,
266266
status: "ready",
267+
runtimeMode: input.runtimeMode,
267268
threadId,
268269
cwd: input.cwd,
269270
resumeCursor: input.resumeCursor??{ sessionId },

‎apps/server/integration/orchestrationEngine.integration.test.ts‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,16 @@ function withHarness<A, E>(
9797
);
9898
}
9999

100+
functionwithRealCodexHarness<A,E>(
101+
use: (harness: OrchestrationIntegrationHarness)=>Effect.Effect<A,E>,
102+
){
103+
returnEffect.acquireUseRelease(
104+
makeOrchestrationIntegrationHarness({provider: "codex",realCodex: true}),
105+
use,
106+
(harness)=>harness.dispose,
107+
);
108+
}
109+
100110
constseedProjectAndThread=(harness: OrchestrationIntegrationHarness)=>
101111
Effect.gen(function*(){
102112
constcreatedAt=nowIso();
@@ -118,6 +128,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>
118128
projectId: PROJECT_ID,
119129
title: "Integration Thread",
120130
model: "gpt-5-codex",
131+
runtimeMode: "approval-required",
121132
branch: null,
122133
worktreePath: harness.workspaceDir,
123134
createdAt,
@@ -216,6 +227,97 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite +
216227
),
217228
);
218229

230+
it.live.skipIf(!process.env.CODEX_BINARY_PATH)(
231+
"keeps the same Codex provider thread across runtime mode switches",
232+
()=>
233+
withRealCodexHarness((harness)=>
234+
Effect.gen(function*(){
235+
constcreatedAt=nowIso();
236+
237+
yield*harness.engine.dispatch({
238+
type: "project.create",
239+
commandId: CommandId.makeUnsafe("cmd-project-create-real-codex"),
240+
projectId: PROJECT_ID,
241+
title: "Integration Project",
242+
workspaceRoot: harness.workspaceDir,
243+
defaultModel: "gpt-5.3-codex",
244+
createdAt,
245+
});
246+
247+
yield*harness.engine.dispatch({
248+
type: "thread.create",
249+
commandId: CommandId.makeUnsafe("cmd-thread-create-real-codex"),
250+
threadId: THREAD_ID,
251+
projectId: PROJECT_ID,
252+
title: "Integration Thread",
253+
model: "gpt-5.3-codex",
254+
runtimeMode: "full-access",
255+
branch: null,
256+
worktreePath: harness.workspaceDir,
257+
createdAt,
258+
});
259+
260+
yield*harness.engine.dispatch({
261+
type: "thread.turn.start",
262+
commandId: CommandId.makeUnsafe("cmd-turn-start-real-codex-1"),
263+
threadId: THREAD_ID,
264+
message: {
265+
messageId: asMessageId("msg-real-codex-1"),
266+
role: "user",
267+
text: "Reply with exactly ALPHA.",
268+
attachments: [],
269+
},
270+
runtimeMode: "full-access",
271+
createdAt: nowIso(),
272+
});
273+
274+
constfirstThread=yield*harness.waitForThread(
275+
THREAD_ID,
276+
(entry)=>
277+
entry.session?.status==="ready"&&
278+
entry.session.providerName==="codex"&&
279+
entry.session.providerThreadId!==null&&
280+
entry.messages.some(
281+
(message)=>message.role==="assistant"&&message.streaming===false,
282+
),
283+
180_000,
284+
);
285+
286+
constoriginalProviderThreadId=firstThread.session?.providerThreadId;
287+
assert.isNotNull(originalProviderThreadId);
288+
289+
yield*harness.engine.dispatch({
290+
type: "thread.turn.start",
291+
commandId: CommandId.makeUnsafe("cmd-turn-start-real-codex-2"),
292+
threadId: THREAD_ID,
293+
message: {
294+
messageId: asMessageId("msg-real-codex-2"),
295+
role: "user",
296+
text: "Reply with exactly BETA.",
297+
attachments: [],
298+
},
299+
runtimeMode: "approval-required",
300+
createdAt: nowIso(),
301+
});
302+
303+
constsecondThread=yield*harness.waitForThread(
304+
THREAD_ID,
305+
(entry)=>
306+
entry.session?.status==="ready"&&
307+
entry.session.providerName==="codex"&&
308+
entry.session.providerThreadId!==null&&
309+
entry.session.runtimeMode==="approval-required"&&
310+
entry.messages.some(
311+
(message)=>message.role==="assistant"&&message.text.includes("BETA"),
312+
),
313+
180_000,
314+
);
315+
316+
assert.equal(secondThread.session?.providerThreadId,originalProviderThreadId);
317+
}),
318+
),
319+
);
320+
219321
it.live("runs multi-turn file edits and persists checkpoint diffs",()=>
220322
withHarness((harness)=>
221323
Effect.gen(function*(){

‎apps/server/integration/providerService.integration.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ it.effect("replays typed runtime fixture events", () =>
122122
{
123123
provider: "codex",
124124
cwd: fixture.cwd,
125+
runtimeMode: "full-access",
125126
},
126127
);
127128
assert.equal((session.threadId??"").length>0,true);
@@ -155,6 +156,7 @@ it.effect("replays file-changing fixture turn events", () =>
155156
{
156157
provider: "codex",
157158
cwd: fixture.cwd,
159+
runtimeMode: "full-access",
158160
},
159161
);
160162
assert.equal((session.threadId??"").length>0,true);
@@ -192,6 +194,7 @@ it.effect("runs multi-turn tool/approval flow", () =>
192194
{
193195
provider: "codex",
194196
cwd: fixture.cwd,
197+
runtimeMode: "full-access",
195198
},
196199
);
197200
assert.equal((session.threadId??"").length>0,true);
@@ -244,6 +247,7 @@ it.effect("rolls back provider conversation state only", () =>
244247
{
245248
provider: "codex",
246249
cwd: fixture.cwd,
250+
runtimeMode: "full-access",
247251
},
248252
);
249253
assert.equal((session.threadId??"").length>0,true);

‎apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ function makeSnapshot(input: {
4343
projectId: input.projectId,
4444
title: "Thread",
4545
model: "gpt-5-codex",
46+
runtimeMode: "full-access",
4647
branch: null,
4748
worktreePath: input.worktreePath,
4849
latestTurn: {

‎apps/server/src/codexAppServerManager.test.ts‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import{describe,expect,it,vi}from"vitest";
2+
import{randomUUID}from"node:crypto";
3+
import{mkdtempSync,rmSync,writeFileSync}from"node:fs";
4+
importosfrom"node:os";
5+
importpathfrom"node:path";
26
import{ProviderSessionId}from"@t3tools/contracts";
37

48
import{
@@ -165,6 +169,7 @@ describe("startSession", () => {
165169
awaitexpect(
166170
manager.startSession({
167171
provider: "codex",
172+
runtimeMode: "full-access",
168173
}),
169174
).rejects.toThrow("cwd missing");
170175
expect(events).toHaveLength(1);
@@ -347,3 +352,84 @@ describe("thread checkpoint control", () => {
347352
});
348353
});
349354
});
355+
356+
describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume",()=>{
357+
it(
358+
"keeps prior thread history when resuming with a changed runtime mode",
359+
async()=>{
360+
constworkspaceDir=mkdtempSync(path.join(os.tmpdir(),"codex-live-resume-"));
361+
writeFileSync(path.join(workspaceDir,"README.md"),"hello\n","utf8");
362+
363+
constmanager=newCodexAppServerManager();
364+
365+
try{
366+
constfirstSession=awaitmanager.startSession({
367+
provider: "codex",
368+
cwd: workspaceDir,
369+
runtimeMode: "full-access",
370+
providerOptions: {
371+
codex: {
372+
binaryPath: process.env.CODEX_BINARY_PATH,
373+
...(process.env.CODEX_HOME_PATH
374+
? {homePath: process.env.CODEX_HOME_PATH}
375+
: {}),
376+
},
377+
},
378+
});
379+
380+
constfirstTurn=awaitmanager.sendTurn({
381+
sessionId: firstSession.sessionId,
382+
input: `Reply with exactly the word ALPHA ${randomUUID()}`,
383+
});
384+
385+
expect(firstTurn.threadId).toBe(firstSession.threadId);
386+
387+
awaitvi.waitFor(async()=>{
388+
constsnapshot=awaitmanager.readThread(firstSession.sessionId);
389+
expect(snapshot.turns.length).toBeGreaterThan(0);
390+
},{timeout: 120_000,interval: 1_000});
391+
392+
constfirstSnapshot=awaitmanager.readThread(firstSession.sessionId);
393+
constoriginalThreadId=firstSnapshot.threadId;
394+
constoriginalTurnCount=firstSnapshot.turns.length;
395+
396+
manager.stopSession(firstSession.sessionId);
397+
398+
constresumedSession=awaitmanager.startSession({
399+
provider: "codex",
400+
cwd: workspaceDir,
401+
runtimeMode: "approval-required",
402+
resumeCursor: firstSession.resumeCursor,
403+
providerOptions: {
404+
codex: {
405+
binaryPath: process.env.CODEX_BINARY_PATH,
406+
...(process.env.CODEX_HOME_PATH
407+
? {homePath: process.env.CODEX_HOME_PATH}
408+
: {}),
409+
},
410+
},
411+
});
412+
413+
expect(resumedSession.threadId).toBe(originalThreadId);
414+
415+
constresumedSnapshotBeforeTurn=awaitmanager.readThread(resumedSession.sessionId);
416+
expect(resumedSnapshotBeforeTurn.threadId).toBe(originalThreadId);
417+
expect(resumedSnapshotBeforeTurn.turns.length).toBeGreaterThanOrEqual(originalTurnCount);
418+
419+
awaitmanager.sendTurn({
420+
sessionId: resumedSession.sessionId,
421+
input: `Reply with exactly the word BETA ${randomUUID()}`,
422+
});
423+
424+
awaitvi.waitFor(async()=>{
425+
constsnapshot=awaitmanager.readThread(resumedSession.sessionId);
426+
expect(snapshot.turns.length).toBeGreaterThan(originalTurnCount);
427+
},{timeout: 120_000,interval: 1_000});
428+
}finally{
429+
manager.stopAll();
430+
rmSync(workspaceDir,{recursive: true,force: true});
431+
}
432+
},
433+
180_000,
434+
);
435+
});

‎apps/server/src/codexAppServerManager.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
204204
sessionId,
205205
provider: "codex",
206206
status: "connecting",
207+
runtimeMode: input.runtimeMode,
207208
model: normalizeCodexModelSlug(input.model),
208209
cwd: resolvedCwd,
209210
createdAt: now,

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 689dda5

Browse files
Persist thread runtime mode and handle mode-switch session restarts
- Add `thread.runtime-mode.set` -> `thread.runtime-mode-set` flow in decider/reactors/projectors - Persist runtime mode on thread/session projections with new DB migrations - Update provider and Codex integration tests to verify thread continuity across mode changes Co-authored-by: codex <codex@users.noreply.github.com>
1 parent 0a67f9d commit 689dda5

51 files changed

Lines changed: 743 additions & 173 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/server/integration/OrchestrationEngineHarness.integration.ts‎

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ import { ProviderUnsupportedError } from "../src/provider/Errors.ts";
3737
import{ProviderAdapterRegistry}from"../src/provider/Services/ProviderAdapterRegistry.ts";
3838
import{ProviderSessionDirectoryLive}from"../src/provider/Layers/ProviderSessionDirectory.ts";
3939
import{makeProviderServiceLive}from"../src/provider/Layers/ProviderService.ts";
40+
import{makeCodexAdapterLive}from"../src/provider/Layers/CodexAdapter.ts";
41+
import{CodexAdapter}from"../src/provider/Services/CodexAdapter.ts";
4042
import{ProviderService}from"../src/provider/Services/ProviderService.ts";
4143
import{CheckpointReactorLive}from"../src/orchestration/Layers/CheckpointReactor.ts";
4244
import{OrchestrationEngineLive}from"../src/orchestration/Layers/OrchestrationEngine.ts";
@@ -187,6 +189,7 @@ export interface OrchestrationIntegrationHarness {
187189

188190
interfaceMakeOrchestrationIntegrationHarnessOptions{
189191
readonlyprovider?: "codex"|"claudeCode";
192+
readonlyrealCodex?: boolean;
190193
}
191194

192195
exportconstmakeOrchestrationIntegrationHarness=(
@@ -195,10 +198,21 @@ export const makeOrchestrationIntegrationHarness = (
195198
Effect.gen(function*(){
196199
constsleep=(ms: number)=>Effect.sleep(ms);
197200
constprovider=options?.provider??"codex";
198-
constadapterHarness=yield*makeTestProviderAdapterHarness({
199-
provider,
200-
});
201-
201+
constuseRealCodex=options?.realCodex===true;
202+
constadapterHarness=useRealCodex
203+
? null
204+
: yield*makeTestProviderAdapterHarness({
205+
provider,
206+
});
207+
constfakeRegistry=adapterHarness
208+
? Layer.succeed(ProviderAdapterRegistry,{
209+
getByProvider: (resolvedProvider)=>
210+
resolvedProvider===adapterHarness.provider
211+
? Effect.succeed(adapterHarness.adapter)
212+
: Effect.fail(newProviderUnsupportedError({provider: resolvedProvider})),
213+
listProviders: ()=>Effect.succeed([adapterHarness.provider]),
214+
}astypeofProviderAdapterRegistry.Service)
215+
: null;
202216
constrootDir=fs.mkdtempSync(path.join(os.tmpdir(),"t3-orchestration-integration-"));
203217
constworkspaceDir=path.join(rootDir,"workspace");
204218
conststateDir=path.join(rootDir,"state");
@@ -207,14 +221,6 @@ export const makeOrchestrationIntegrationHarness = (
207221
fs.mkdirSync(stateDir,{recursive: true});
208222
initializeGitWorkspace(workspaceDir);
209223

210-
constregistry: typeofProviderAdapterRegistry.Service={
211-
getByProvider: (provider)=>
212-
provider===adapterHarness.provider
213-
? Effect.succeed(adapterHarness.adapter)
214-
: Effect.fail(newProviderUnsupportedError({ provider })),
215-
listProviders: ()=>Effect.succeed([adapterHarness.provider]),
216-
};
217-
218224
constpersistenceLayer=makeSqlitePersistenceLive(dbPath);
219225
constorchestrationLayer=OrchestrationEngineLive.pipe(
220226
Layer.provide(OrchestrationProjectionPipelineLive),
@@ -224,10 +230,33 @@ export const makeOrchestrationIntegrationHarness = (
224230
constproviderSessionDirectoryLayer=ProviderSessionDirectoryLive.pipe(
225231
Layer.provide(ProviderSessionRuntimeRepositoryLive),
226232
);
227-
constproviderLayer=makeProviderServiceLive().pipe(
228-
Layer.provide(providerSessionDirectoryLayer),
229-
Layer.provide(Layer.succeed(ProviderAdapterRegistry,registry)),
233+
constrealCodexRegistry=Layer.effect(
234+
ProviderAdapterRegistry,
235+
Effect.gen(function*(){
236+
constcodexAdapter=yield*CodexAdapter;
237+
return{
238+
getByProvider: (resolvedProvider)=>
239+
resolvedProvider==="codex"
240+
? Effect.succeed(codexAdapter)
241+
: Effect.fail(newProviderUnsupportedError({provider: resolvedProvider})),
242+
listProviders: ()=>Effect.succeed(["codex"]asconst),
243+
}astypeofProviderAdapterRegistry.Service;
244+
}),
245+
).pipe(
246+
Layer.provide(makeCodexAdapterLive()),
247+
Layer.provideMerge(ServerConfig.layerTest(workspaceDir,stateDir)),
248+
Layer.provideMerge(NodeServices.layer),
249+
Layer.provideMerge(providerSessionDirectoryLayer),
230250
);
251+
constproviderLayer=useRealCodex
252+
? makeProviderServiceLive().pipe(
253+
Layer.provide(providerSessionDirectoryLayer),
254+
Layer.provide(realCodexRegistry),
255+
)
256+
: makeProviderServiceLive().pipe(
257+
Layer.provide(providerSessionDirectoryLayer),
258+
Layer.provide(fakeRegistry!),
259+
);
231260

232261
construntimeServicesLayer=Layer.mergeAll(
233262
orchestrationLayer,
@@ -407,8 +436,8 @@ export const makeOrchestrationIntegrationHarness = (
407436
return{
408437
rootDir,
409438
workspaceDir,
410-
dbPath,
411-
adapterHarness,
439+
dbPath,
440+
adapterHarness: adapterHarnessasTestProviderAdapterHarness,
412441
engine,
413442
snapshotQuery,
414443
providerService,

‎apps/server/integration/TestProviderAdapter.integration.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
264264
sessionId,
265265
provider,
266266
status: "ready",
267+
runtimeMode: input.runtimeMode,
267268
threadId,
268269
cwd: input.cwd,
269270
resumeCursor: input.resumeCursor??{ sessionId },

‎apps/server/integration/orchestrationEngine.integration.test.ts‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,16 @@ function withHarness<A, E>(
9797
);
9898
}
9999

100+
functionwithRealCodexHarness<A,E>(
101+
use: (harness: OrchestrationIntegrationHarness)=>Effect.Effect<A,E>,
102+
){
103+
returnEffect.acquireUseRelease(
104+
makeOrchestrationIntegrationHarness({provider: "codex",realCodex: true}),
105+
use,
106+
(harness)=>harness.dispose,
107+
);
108+
}
109+
100110
constseedProjectAndThread=(harness: OrchestrationIntegrationHarness)=>
101111
Effect.gen(function*(){
102112
constcreatedAt=nowIso();
@@ -118,6 +128,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>
118128
projectId: PROJECT_ID,
119129
title: "Integration Thread",
120130
model: "gpt-5-codex",
131+
runtimeMode: "approval-required",
121132
branch: null,
122133
worktreePath: harness.workspaceDir,
123134
createdAt,
@@ -216,6 +227,97 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite +
216227
),
217228
);
218229

230+
it.live.skipIf(!process.env.CODEX_BINARY_PATH)(
231+
"keeps the same Codex provider thread across runtime mode switches",
232+
()=>
233+
withRealCodexHarness((harness)=>
234+
Effect.gen(function*(){
235+
constcreatedAt=nowIso();
236+
237+
yield*harness.engine.dispatch({
238+
type: "project.create",
239+
commandId: CommandId.makeUnsafe("cmd-project-create-real-codex"),
240+
projectId: PROJECT_ID,
241+
title: "Integration Project",
242+
workspaceRoot: harness.workspaceDir,
243+
defaultModel: "gpt-5.3-codex",
244+
createdAt,
245+
});
246+
247+
yield*harness.engine.dispatch({
248+
type: "thread.create",
249+
commandId: CommandId.makeUnsafe("cmd-thread-create-real-codex"),
250+
threadId: THREAD_ID,
251+
projectId: PROJECT_ID,
252+
title: "Integration Thread",
253+
model: "gpt-5.3-codex",
254+
runtimeMode: "full-access",
255+
branch: null,
256+
worktreePath: harness.workspaceDir,
257+
createdAt,
258+
});
259+
260+
yield*harness.engine.dispatch({
261+
type: "thread.turn.start",
262+
commandId: CommandId.makeUnsafe("cmd-turn-start-real-codex-1"),
263+
threadId: THREAD_ID,
264+
message: {
265+
messageId: asMessageId("msg-real-codex-1"),
266+
role: "user",
267+
text: "Reply with exactly ALPHA.",
268+
attachments: [],
269+
},
270+
runtimeMode: "full-access",
271+
createdAt: nowIso(),
272+
});
273+
274+
constfirstThread=yield*harness.waitForThread(
275+
THREAD_ID,
276+
(entry)=>
277+
entry.session?.status==="ready"&&
278+
entry.session.providerName==="codex"&&
279+
entry.session.providerThreadId!==null&&
280+
entry.messages.some(
281+
(message)=>message.role==="assistant"&&message.streaming===false,
282+
),
283+
180_000,
284+
);
285+
286+
constoriginalProviderThreadId=firstThread.session?.providerThreadId;
287+
assert.isNotNull(originalProviderThreadId);
288+
289+
yield*harness.engine.dispatch({
290+
type: "thread.turn.start",
291+
commandId: CommandId.makeUnsafe("cmd-turn-start-real-codex-2"),
292+
threadId: THREAD_ID,
293+
message: {
294+
messageId: asMessageId("msg-real-codex-2"),
295+
role: "user",
296+
text: "Reply with exactly BETA.",
297+
attachments: [],
298+
},
299+
runtimeMode: "approval-required",
300+
createdAt: nowIso(),
301+
});
302+
303+
constsecondThread=yield*harness.waitForThread(
304+
THREAD_ID,
305+
(entry)=>
306+
entry.session?.status==="ready"&&
307+
entry.session.providerName==="codex"&&
308+
entry.session.providerThreadId!==null&&
309+
entry.session.runtimeMode==="approval-required"&&
310+
entry.messages.some(
311+
(message)=>message.role==="assistant"&&message.text.includes("BETA"),
312+
),
313+
180_000,
314+
);
315+
316+
assert.equal(secondThread.session?.providerThreadId,originalProviderThreadId);
317+
}),
318+
),
319+
);
320+
219321
it.live("runs multi-turn file edits and persists checkpoint diffs",()=>
220322
withHarness((harness)=>
221323
Effect.gen(function*(){

‎apps/server/integration/providerService.integration.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ it.effect("replays typed runtime fixture events", () =>
122122
{
123123
provider: "codex",
124124
cwd: fixture.cwd,
125+
runtimeMode: "full-access",
125126
},
126127
);
127128
assert.equal((session.threadId??"").length>0,true);
@@ -155,6 +156,7 @@ it.effect("replays file-changing fixture turn events", () =>
155156
{
156157
provider: "codex",
157158
cwd: fixture.cwd,
159+
runtimeMode: "full-access",
158160
},
159161
);
160162
assert.equal((session.threadId??"").length>0,true);
@@ -192,6 +194,7 @@ it.effect("runs multi-turn tool/approval flow", () =>
192194
{
193195
provider: "codex",
194196
cwd: fixture.cwd,
197+
runtimeMode: "full-access",
195198
},
196199
);
197200
assert.equal((session.threadId??"").length>0,true);
@@ -244,6 +247,7 @@ it.effect("rolls back provider conversation state only", () =>
244247
{
245248
provider: "codex",
246249
cwd: fixture.cwd,
250+
runtimeMode: "full-access",
247251
},
248252
);
249253
assert.equal((session.threadId??"").length>0,true);

‎apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ function makeSnapshot(input: {
4343
projectId: input.projectId,
4444
title: "Thread",
4545
model: "gpt-5-codex",
46+
runtimeMode: "full-access",
4647
branch: null,
4748
worktreePath: input.worktreePath,
4849
latestTurn: {

‎apps/server/src/codexAppServerManager.test.ts‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import{describe,expect,it,vi}from"vitest";
2+
import{randomUUID}from"node:crypto";
3+
import{mkdtempSync,rmSync,writeFileSync}from"node:fs";
4+
importosfrom"node:os";
5+
importpathfrom"node:path";
26
import{ProviderSessionId}from"@t3tools/contracts";
37

48
import{
@@ -165,6 +169,7 @@ describe("startSession", () => {
165169
awaitexpect(
166170
manager.startSession({
167171
provider: "codex",
172+
runtimeMode: "full-access",
168173
}),
169174
).rejects.toThrow("cwd missing");
170175
expect(events).toHaveLength(1);
@@ -347,3 +352,84 @@ describe("thread checkpoint control", () => {
347352
});
348353
});
349354
});
355+
356+
describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume",()=>{
357+
it(
358+
"keeps prior thread history when resuming with a changed runtime mode",
359+
async()=>{
360+
constworkspaceDir=mkdtempSync(path.join(os.tmpdir(),"codex-live-resume-"));
361+
writeFileSync(path.join(workspaceDir,"README.md"),"hello\n","utf8");
362+
363+
constmanager=newCodexAppServerManager();
364+
365+
try{
366+
constfirstSession=awaitmanager.startSession({
367+
provider: "codex",
368+
cwd: workspaceDir,
369+
runtimeMode: "full-access",
370+
providerOptions: {
371+
codex: {
372+
binaryPath: process.env.CODEX_BINARY_PATH,
373+
...(process.env.CODEX_HOME_PATH
374+
? {homePath: process.env.CODEX_HOME_PATH}
375+
: {}),
376+
},
377+
},
378+
});
379+
380+
constfirstTurn=awaitmanager.sendTurn({
381+
sessionId: firstSession.sessionId,
382+
input: `Reply with exactly the word ALPHA ${randomUUID()}`,
383+
});
384+
385+
expect(firstTurn.threadId).toBe(firstSession.threadId);
386+
387+
awaitvi.waitFor(async()=>{
388+
constsnapshot=awaitmanager.readThread(firstSession.sessionId);
389+
expect(snapshot.turns.length).toBeGreaterThan(0);
390+
},{timeout: 120_000,interval: 1_000});
391+
392+
constfirstSnapshot=awaitmanager.readThread(firstSession.sessionId);
393+
constoriginalThreadId=firstSnapshot.threadId;
394+
constoriginalTurnCount=firstSnapshot.turns.length;
395+
396+
manager.stopSession(firstSession.sessionId);
397+
398+
constresumedSession=awaitmanager.startSession({
399+
provider: "codex",
400+
cwd: workspaceDir,
401+
runtimeMode: "approval-required",
402+
resumeCursor: firstSession.resumeCursor,
403+
providerOptions: {
404+
codex: {
405+
binaryPath: process.env.CODEX_BINARY_PATH,
406+
...(process.env.CODEX_HOME_PATH
407+
? {homePath: process.env.CODEX_HOME_PATH}
408+
: {}),
409+
},
410+
},
411+
});
412+
413+
expect(resumedSession.threadId).toBe(originalThreadId);
414+
415+
constresumedSnapshotBeforeTurn=awaitmanager.readThread(resumedSession.sessionId);
416+
expect(resumedSnapshotBeforeTurn.threadId).toBe(originalThreadId);
417+
expect(resumedSnapshotBeforeTurn.turns.length).toBeGreaterThanOrEqual(originalTurnCount);
418+
419+
awaitmanager.sendTurn({
420+
sessionId: resumedSession.sessionId,
421+
input: `Reply with exactly the word BETA ${randomUUID()}`,
422+
});
423+
424+
awaitvi.waitFor(async()=>{
425+
constsnapshot=awaitmanager.readThread(resumedSession.sessionId);
426+
expect(snapshot.turns.length).toBeGreaterThan(originalTurnCount);
427+
},{timeout: 120_000,interval: 1_000});
428+
}finally{
429+
manager.stopAll();
430+
rmSync(workspaceDir,{recursive: true,force: true});
431+
}
432+
},
433+
180_000,
434+
);
435+
});

‎apps/server/src/codexAppServerManager.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
204204
sessionId,
205205
provider: "codex",
206206
status: "connecting",
207+
runtimeMode: input.runtimeMode,
207208
model: normalizeCodexModelSlug(input.model),
208209
cwd: resolvedCwd,
209210
createdAt: now,

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 689dda5

Browse files
Persist thread runtime mode and handle mode-switch session restarts
- Add `thread.runtime-mode.set` -> `thread.runtime-mode-set` flow in decider/reactors/projectors - Persist runtime mode on thread/session projections with new DB migrations - Update provider and Codex integration tests to verify thread continuity across mode changes Co-authored-by: codex <codex@users.noreply.github.com>
1 parent 0a67f9d commit 689dda5

51 files changed

Lines changed: 743 additions & 173 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/server/integration/OrchestrationEngineHarness.integration.ts‎

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ import { ProviderUnsupportedError } from "../src/provider/Errors.ts";
3737
import{ProviderAdapterRegistry}from"../src/provider/Services/ProviderAdapterRegistry.ts";
3838
import{ProviderSessionDirectoryLive}from"../src/provider/Layers/ProviderSessionDirectory.ts";
3939
import{makeProviderServiceLive}from"../src/provider/Layers/ProviderService.ts";
40+
import{makeCodexAdapterLive}from"../src/provider/Layers/CodexAdapter.ts";
41+
import{CodexAdapter}from"../src/provider/Services/CodexAdapter.ts";
4042
import{ProviderService}from"../src/provider/Services/ProviderService.ts";
4143
import{CheckpointReactorLive}from"../src/orchestration/Layers/CheckpointReactor.ts";
4244
import{OrchestrationEngineLive}from"../src/orchestration/Layers/OrchestrationEngine.ts";
@@ -187,6 +189,7 @@ export interface OrchestrationIntegrationHarness {
187189

188190
interfaceMakeOrchestrationIntegrationHarnessOptions{
189191
readonlyprovider?: "codex"|"claudeCode";
192+
readonlyrealCodex?: boolean;
190193
}
191194

192195
exportconstmakeOrchestrationIntegrationHarness=(
@@ -195,10 +198,21 @@ export const makeOrchestrationIntegrationHarness = (
195198
Effect.gen(function*(){
196199
constsleep=(ms: number)=>Effect.sleep(ms);
197200
constprovider=options?.provider??"codex";
198-
constadapterHarness=yield*makeTestProviderAdapterHarness({
199-
provider,
200-
});
201-
201+
constuseRealCodex=options?.realCodex===true;
202+
constadapterHarness=useRealCodex
203+
? null
204+
: yield*makeTestProviderAdapterHarness({
205+
provider,
206+
});
207+
constfakeRegistry=adapterHarness
208+
? Layer.succeed(ProviderAdapterRegistry,{
209+
getByProvider: (resolvedProvider)=>
210+
resolvedProvider===adapterHarness.provider
211+
? Effect.succeed(adapterHarness.adapter)
212+
: Effect.fail(newProviderUnsupportedError({provider: resolvedProvider})),
213+
listProviders: ()=>Effect.succeed([adapterHarness.provider]),
214+
}astypeofProviderAdapterRegistry.Service)
215+
: null;
202216
constrootDir=fs.mkdtempSync(path.join(os.tmpdir(),"t3-orchestration-integration-"));
203217
constworkspaceDir=path.join(rootDir,"workspace");
204218
conststateDir=path.join(rootDir,"state");
@@ -207,14 +221,6 @@ export const makeOrchestrationIntegrationHarness = (
207221
fs.mkdirSync(stateDir,{recursive: true});
208222
initializeGitWorkspace(workspaceDir);
209223

210-
constregistry: typeofProviderAdapterRegistry.Service={
211-
getByProvider: (provider)=>
212-
provider===adapterHarness.provider
213-
? Effect.succeed(adapterHarness.adapter)
214-
: Effect.fail(newProviderUnsupportedError({ provider })),
215-
listProviders: ()=>Effect.succeed([adapterHarness.provider]),
216-
};
217-
218224
constpersistenceLayer=makeSqlitePersistenceLive(dbPath);
219225
constorchestrationLayer=OrchestrationEngineLive.pipe(
220226
Layer.provide(OrchestrationProjectionPipelineLive),
@@ -224,10 +230,33 @@ export const makeOrchestrationIntegrationHarness = (
224230
constproviderSessionDirectoryLayer=ProviderSessionDirectoryLive.pipe(
225231
Layer.provide(ProviderSessionRuntimeRepositoryLive),
226232
);
227-
constproviderLayer=makeProviderServiceLive().pipe(
228-
Layer.provide(providerSessionDirectoryLayer),
229-
Layer.provide(Layer.succeed(ProviderAdapterRegistry,registry)),
233+
constrealCodexRegistry=Layer.effect(
234+
ProviderAdapterRegistry,
235+
Effect.gen(function*(){
236+
constcodexAdapter=yield*CodexAdapter;
237+
return{
238+
getByProvider: (resolvedProvider)=>
239+
resolvedProvider==="codex"
240+
? Effect.succeed(codexAdapter)
241+
: Effect.fail(newProviderUnsupportedError({provider: resolvedProvider})),
242+
listProviders: ()=>Effect.succeed(["codex"]asconst),
243+
}astypeofProviderAdapterRegistry.Service;
244+
}),
245+
).pipe(
246+
Layer.provide(makeCodexAdapterLive()),
247+
Layer.provideMerge(ServerConfig.layerTest(workspaceDir,stateDir)),
248+
Layer.provideMerge(NodeServices.layer),
249+
Layer.provideMerge(providerSessionDirectoryLayer),
230250
);
251+
constproviderLayer=useRealCodex
252+
? makeProviderServiceLive().pipe(
253+
Layer.provide(providerSessionDirectoryLayer),
254+
Layer.provide(realCodexRegistry),
255+
)
256+
: makeProviderServiceLive().pipe(
257+
Layer.provide(providerSessionDirectoryLayer),
258+
Layer.provide(fakeRegistry!),
259+
);
231260

232261
construntimeServicesLayer=Layer.mergeAll(
233262
orchestrationLayer,
@@ -407,8 +436,8 @@ export const makeOrchestrationIntegrationHarness = (
407436
return{
408437
rootDir,
409438
workspaceDir,
410-
dbPath,
411-
adapterHarness,
439+
dbPath,
440+
adapterHarness: adapterHarnessasTestProviderAdapterHarness,
412441
engine,
413442
snapshotQuery,
414443
providerService,

‎apps/server/integration/TestProviderAdapter.integration.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
264264
sessionId,
265265
provider,
266266
status: "ready",
267+
runtimeMode: input.runtimeMode,
267268
threadId,
268269
cwd: input.cwd,
269270
resumeCursor: input.resumeCursor??{ sessionId },

‎apps/server/integration/orchestrationEngine.integration.test.ts‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,16 @@ function withHarness<A, E>(
9797
);
9898
}
9999

100+
functionwithRealCodexHarness<A,E>(
101+
use: (harness: OrchestrationIntegrationHarness)=>Effect.Effect<A,E>,
102+
){
103+
returnEffect.acquireUseRelease(
104+
makeOrchestrationIntegrationHarness({provider: "codex",realCodex: true}),
105+
use,
106+
(harness)=>harness.dispose,
107+
);
108+
}
109+
100110
constseedProjectAndThread=(harness: OrchestrationIntegrationHarness)=>
101111
Effect.gen(function*(){
102112
constcreatedAt=nowIso();
@@ -118,6 +128,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>
118128
projectId: PROJECT_ID,
119129
title: "Integration Thread",
120130
model: "gpt-5-codex",
131+
runtimeMode: "approval-required",
121132
branch: null,
122133
worktreePath: harness.workspaceDir,
123134
createdAt,
@@ -216,6 +227,97 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite +
216227
),
217228
);
218229

230+
it.live.skipIf(!process.env.CODEX_BINARY_PATH)(
231+
"keeps the same Codex provider thread across runtime mode switches",
232+
()=>
233+
withRealCodexHarness((harness)=>
234+
Effect.gen(function*(){
235+
constcreatedAt=nowIso();
236+
237+
yield*harness.engine.dispatch({
238+
type: "project.create",
239+
commandId: CommandId.makeUnsafe("cmd-project-create-real-codex"),
240+
projectId: PROJECT_ID,
241+
title: "Integration Project",
242+
workspaceRoot: harness.workspaceDir,
243+
defaultModel: "gpt-5.3-codex",
244+
createdAt,
245+
});
246+
247+
yield*harness.engine.dispatch({
248+
type: "thread.create",
249+
commandId: CommandId.makeUnsafe("cmd-thread-create-real-codex"),
250+
threadId: THREAD_ID,
251+
projectId: PROJECT_ID,
252+
title: "Integration Thread",
253+
model: "gpt-5.3-codex",
254+
runtimeMode: "full-access",
255+
branch: null,
256+
worktreePath: harness.workspaceDir,
257+
createdAt,
258+
});
259+
260+
yield*harness.engine.dispatch({
261+
type: "thread.turn.start",
262+
commandId: CommandId.makeUnsafe("cmd-turn-start-real-codex-1"),
263+
threadId: THREAD_ID,
264+
message: {
265+
messageId: asMessageId("msg-real-codex-1"),
266+
role: "user",
267+
text: "Reply with exactly ALPHA.",
268+
attachments: [],
269+
},
270+
runtimeMode: "full-access",
271+
createdAt: nowIso(),
272+
});
273+
274+
constfirstThread=yield*harness.waitForThread(
275+
THREAD_ID,
276+
(entry)=>
277+
entry.session?.status==="ready"&&
278+
entry.session.providerName==="codex"&&
279+
entry.session.providerThreadId!==null&&
280+
entry.messages.some(
281+
(message)=>message.role==="assistant"&&message.streaming===false,
282+
),
283+
180_000,
284+
);
285+
286+
constoriginalProviderThreadId=firstThread.session?.providerThreadId;
287+
assert.isNotNull(originalProviderThreadId);
288+
289+
yield*harness.engine.dispatch({
290+
type: "thread.turn.start",
291+
commandId: CommandId.makeUnsafe("cmd-turn-start-real-codex-2"),
292+
threadId: THREAD_ID,
293+
message: {
294+
messageId: asMessageId("msg-real-codex-2"),
295+
role: "user",
296+
text: "Reply with exactly BETA.",
297+
attachments: [],
298+
},
299+
runtimeMode: "approval-required",
300+
createdAt: nowIso(),
301+
});
302+
303+
constsecondThread=yield*harness.waitForThread(
304+
THREAD_ID,
305+
(entry)=>
306+
entry.session?.status==="ready"&&
307+
entry.session.providerName==="codex"&&
308+
entry.session.providerThreadId!==null&&
309+
entry.session.runtimeMode==="approval-required"&&
310+
entry.messages.some(
311+
(message)=>message.role==="assistant"&&message.text.includes("BETA"),
312+
),
313+
180_000,
314+
);
315+
316+
assert.equal(secondThread.session?.providerThreadId,originalProviderThreadId);
317+
}),
318+
),
319+
);
320+
219321
it.live("runs multi-turn file edits and persists checkpoint diffs",()=>
220322
withHarness((harness)=>
221323
Effect.gen(function*(){

‎apps/server/integration/providerService.integration.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ it.effect("replays typed runtime fixture events", () =>
122122
{
123123
provider: "codex",
124124
cwd: fixture.cwd,
125+
runtimeMode: "full-access",
125126
},
126127
);
127128
assert.equal((session.threadId??"").length>0,true);
@@ -155,6 +156,7 @@ it.effect("replays file-changing fixture turn events", () =>
155156
{
156157
provider: "codex",
157158
cwd: fixture.cwd,
159+
runtimeMode: "full-access",
158160
},
159161
);
160162
assert.equal((session.threadId??"").length>0,true);
@@ -192,6 +194,7 @@ it.effect("runs multi-turn tool/approval flow", () =>
192194
{
193195
provider: "codex",
194196
cwd: fixture.cwd,
197+
runtimeMode: "full-access",
195198
},
196199
);
197200
assert.equal((session.threadId??"").length>0,true);
@@ -244,6 +247,7 @@ it.effect("rolls back provider conversation state only", () =>
244247
{
245248
provider: "codex",
246249
cwd: fixture.cwd,
250+
runtimeMode: "full-access",
247251
},
248252
);
249253
assert.equal((session.threadId??"").length>0,true);

‎apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ function makeSnapshot(input: {
4343
projectId: input.projectId,
4444
title: "Thread",
4545
model: "gpt-5-codex",
46+
runtimeMode: "full-access",
4647
branch: null,
4748
worktreePath: input.worktreePath,
4849
latestTurn: {

‎apps/server/src/codexAppServerManager.test.ts‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import{describe,expect,it,vi}from"vitest";
2+
import{randomUUID}from"node:crypto";
3+
import{mkdtempSync,rmSync,writeFileSync}from"node:fs";
4+
importosfrom"node:os";
5+
importpathfrom"node:path";
26
import{ProviderSessionId}from"@t3tools/contracts";
37

48
import{
@@ -165,6 +169,7 @@ describe("startSession", () => {
165169
awaitexpect(
166170
manager.startSession({
167171
provider: "codex",
172+
runtimeMode: "full-access",
168173
}),
169174
).rejects.toThrow("cwd missing");
170175
expect(events).toHaveLength(1);
@@ -347,3 +352,84 @@ describe("thread checkpoint control", () => {
347352
});
348353
});
349354
});
355+
356+
describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume",()=>{
357+
it(
358+
"keeps prior thread history when resuming with a changed runtime mode",
359+
async()=>{
360+
constworkspaceDir=mkdtempSync(path.join(os.tmpdir(),"codex-live-resume-"));
361+
writeFileSync(path.join(workspaceDir,"README.md"),"hello\n","utf8");
362+
363+
constmanager=newCodexAppServerManager();
364+
365+
try{
366+
constfirstSession=awaitmanager.startSession({
367+
provider: "codex",
368+
cwd: workspaceDir,
369+
runtimeMode: "full-access",
370+
providerOptions: {
371+
codex: {
372+
binaryPath: process.env.CODEX_BINARY_PATH,
373+
...(process.env.CODEX_HOME_PATH
374+
? {homePath: process.env.CODEX_HOME_PATH}
375+
: {}),
376+
},
377+
},
378+
});
379+
380+
constfirstTurn=awaitmanager.sendTurn({
381+
sessionId: firstSession.sessionId,
382+
input: `Reply with exactly the word ALPHA ${randomUUID()}`,
383+
});
384+
385+
expect(firstTurn.threadId).toBe(firstSession.threadId);
386+
387+
awaitvi.waitFor(async()=>{
388+
constsnapshot=awaitmanager.readThread(firstSession.sessionId);
389+
expect(snapshot.turns.length).toBeGreaterThan(0);
390+
},{timeout: 120_000,interval: 1_000});
391+
392+
constfirstSnapshot=awaitmanager.readThread(firstSession.sessionId);
393+
constoriginalThreadId=firstSnapshot.threadId;
394+
constoriginalTurnCount=firstSnapshot.turns.length;
395+
396+
manager.stopSession(firstSession.sessionId);
397+
398+
constresumedSession=awaitmanager.startSession({
399+
provider: "codex",
400+
cwd: workspaceDir,
401+
runtimeMode: "approval-required",
402+
resumeCursor: firstSession.resumeCursor,
403+
providerOptions: {
404+
codex: {
405+
binaryPath: process.env.CODEX_BINARY_PATH,
406+
...(process.env.CODEX_HOME_PATH
407+
? {homePath: process.env.CODEX_HOME_PATH}
408+
: {}),
409+
},
410+
},
411+
});
412+
413+
expect(resumedSession.threadId).toBe(originalThreadId);
414+
415+
constresumedSnapshotBeforeTurn=awaitmanager.readThread(resumedSession.sessionId);
416+
expect(resumedSnapshotBeforeTurn.threadId).toBe(originalThreadId);
417+
expect(resumedSnapshotBeforeTurn.turns.length).toBeGreaterThanOrEqual(originalTurnCount);
418+
419+
awaitmanager.sendTurn({
420+
sessionId: resumedSession.sessionId,
421+
input: `Reply with exactly the word BETA ${randomUUID()}`,
422+
});
423+
424+
awaitvi.waitFor(async()=>{
425+
constsnapshot=awaitmanager.readThread(resumedSession.sessionId);
426+
expect(snapshot.turns.length).toBeGreaterThan(originalTurnCount);
427+
},{timeout: 120_000,interval: 1_000});
428+
}finally{
429+
manager.stopAll();
430+
rmSync(workspaceDir,{recursive: true,force: true});
431+
}
432+
},
433+
180_000,
434+
);
435+
});

‎apps/server/src/codexAppServerManager.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
204204
sessionId,
205205
provider: "codex",
206206
status: "connecting",
207+
runtimeMode: input.runtimeMode,
207208
model: normalizeCodexModelSlug(input.model),
208209
cwd: resolvedCwd,
209210
createdAt: now,

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Commit 689dda5

Browse files
Persist thread runtime mode and handle mode-switch session restarts
- Add `thread.runtime-mode.set` -> `thread.runtime-mode-set` flow in decider/reactors/projectors - Persist runtime mode on thread/session projections with new DB migrations - Update provider and Codex integration tests to verify thread continuity across mode changes Co-authored-by: codex <codex@users.noreply.github.com>
1 parent 0a67f9d commit 689dda5

51 files changed

Lines changed: 743 additions & 173 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/server/integration/OrchestrationEngineHarness.integration.ts‎

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ import { ProviderUnsupportedError } from "../src/provider/Errors.ts";
3737
import{ProviderAdapterRegistry}from"../src/provider/Services/ProviderAdapterRegistry.ts";
3838
import{ProviderSessionDirectoryLive}from"../src/provider/Layers/ProviderSessionDirectory.ts";
3939
import{makeProviderServiceLive}from"../src/provider/Layers/ProviderService.ts";
40+
import{makeCodexAdapterLive}from"../src/provider/Layers/CodexAdapter.ts";
41+
import{CodexAdapter}from"../src/provider/Services/CodexAdapter.ts";
4042
import{ProviderService}from"../src/provider/Services/ProviderService.ts";
4143
import{CheckpointReactorLive}from"../src/orchestration/Layers/CheckpointReactor.ts";
4244
import{OrchestrationEngineLive}from"../src/orchestration/Layers/OrchestrationEngine.ts";
@@ -187,6 +189,7 @@ export interface OrchestrationIntegrationHarness {
187189

188190
interfaceMakeOrchestrationIntegrationHarnessOptions{
189191
readonlyprovider?: "codex"|"claudeCode";
192+
readonlyrealCodex?: boolean;
190193
}
191194

192195
exportconstmakeOrchestrationIntegrationHarness=(
@@ -195,10 +198,21 @@ export const makeOrchestrationIntegrationHarness = (
195198
Effect.gen(function*(){
196199
constsleep=(ms: number)=>Effect.sleep(ms);
197200
constprovider=options?.provider??"codex";
198-
constadapterHarness=yield*makeTestProviderAdapterHarness({
199-
provider,
200-
});
201-
201+
constuseRealCodex=options?.realCodex===true;
202+
constadapterHarness=useRealCodex
203+
? null
204+
: yield*makeTestProviderAdapterHarness({
205+
provider,
206+
});
207+
constfakeRegistry=adapterHarness
208+
? Layer.succeed(ProviderAdapterRegistry,{
209+
getByProvider: (resolvedProvider)=>
210+
resolvedProvider===adapterHarness.provider
211+
? Effect.succeed(adapterHarness.adapter)
212+
: Effect.fail(newProviderUnsupportedError({provider: resolvedProvider})),
213+
listProviders: ()=>Effect.succeed([adapterHarness.provider]),
214+
}astypeofProviderAdapterRegistry.Service)
215+
: null;
202216
constrootDir=fs.mkdtempSync(path.join(os.tmpdir(),"t3-orchestration-integration-"));
203217
constworkspaceDir=path.join(rootDir,"workspace");
204218
conststateDir=path.join(rootDir,"state");
@@ -207,14 +221,6 @@ export const makeOrchestrationIntegrationHarness = (
207221
fs.mkdirSync(stateDir,{recursive: true});
208222
initializeGitWorkspace(workspaceDir);
209223

210-
constregistry: typeofProviderAdapterRegistry.Service={
211-
getByProvider: (provider)=>
212-
provider===adapterHarness.provider
213-
? Effect.succeed(adapterHarness.adapter)
214-
: Effect.fail(newProviderUnsupportedError({ provider })),
215-
listProviders: ()=>Effect.succeed([adapterHarness.provider]),
216-
};
217-
218224
constpersistenceLayer=makeSqlitePersistenceLive(dbPath);
219225
constorchestrationLayer=OrchestrationEngineLive.pipe(
220226
Layer.provide(OrchestrationProjectionPipelineLive),
@@ -224,10 +230,33 @@ export const makeOrchestrationIntegrationHarness = (
224230
constproviderSessionDirectoryLayer=ProviderSessionDirectoryLive.pipe(
225231
Layer.provide(ProviderSessionRuntimeRepositoryLive),
226232
);
227-
constproviderLayer=makeProviderServiceLive().pipe(
228-
Layer.provide(providerSessionDirectoryLayer),
229-
Layer.provide(Layer.succeed(ProviderAdapterRegistry,registry)),
233+
constrealCodexRegistry=Layer.effect(
234+
ProviderAdapterRegistry,
235+
Effect.gen(function*(){
236+
constcodexAdapter=yield*CodexAdapter;
237+
return{
238+
getByProvider: (resolvedProvider)=>
239+
resolvedProvider==="codex"
240+
? Effect.succeed(codexAdapter)
241+
: Effect.fail(newProviderUnsupportedError({provider: resolvedProvider})),
242+
listProviders: ()=>Effect.succeed(["codex"]asconst),
243+
}astypeofProviderAdapterRegistry.Service;
244+
}),
245+
).pipe(
246+
Layer.provide(makeCodexAdapterLive()),
247+
Layer.provideMerge(ServerConfig.layerTest(workspaceDir,stateDir)),
248+
Layer.provideMerge(NodeServices.layer),
249+
Layer.provideMerge(providerSessionDirectoryLayer),
230250
);
251+
constproviderLayer=useRealCodex
252+
? makeProviderServiceLive().pipe(
253+
Layer.provide(providerSessionDirectoryLayer),
254+
Layer.provide(realCodexRegistry),
255+
)
256+
: makeProviderServiceLive().pipe(
257+
Layer.provide(providerSessionDirectoryLayer),
258+
Layer.provide(fakeRegistry!),
259+
);
231260

232261
construntimeServicesLayer=Layer.mergeAll(
233262
orchestrationLayer,
@@ -407,8 +436,8 @@ export const makeOrchestrationIntegrationHarness = (
407436
return{
408437
rootDir,
409438
workspaceDir,
410-
dbPath,
411-
adapterHarness,
439+
dbPath,
440+
adapterHarness: adapterHarnessasTestProviderAdapterHarness,
412441
engine,
413442
snapshotQuery,
414443
providerService,

‎apps/server/integration/TestProviderAdapter.integration.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
264264
sessionId,
265265
provider,
266266
status: "ready",
267+
runtimeMode: input.runtimeMode,
267268
threadId,
268269
cwd: input.cwd,
269270
resumeCursor: input.resumeCursor??{ sessionId },

‎apps/server/integration/orchestrationEngine.integration.test.ts‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,16 @@ function withHarness<A, E>(
9797
);
9898
}
9999

100+
functionwithRealCodexHarness<A,E>(
101+
use: (harness: OrchestrationIntegrationHarness)=>Effect.Effect<A,E>,
102+
){
103+
returnEffect.acquireUseRelease(
104+
makeOrchestrationIntegrationHarness({provider: "codex",realCodex: true}),
105+
use,
106+
(harness)=>harness.dispose,
107+
);
108+
}
109+
100110
constseedProjectAndThread=(harness: OrchestrationIntegrationHarness)=>
101111
Effect.gen(function*(){
102112
constcreatedAt=nowIso();
@@ -118,6 +128,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>
118128
projectId: PROJECT_ID,
119129
title: "Integration Thread",
120130
model: "gpt-5-codex",
131+
runtimeMode: "approval-required",
121132
branch: null,
122133
worktreePath: harness.workspaceDir,
123134
createdAt,
@@ -216,6 +227,97 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite +
216227
),
217228
);
218229

230+
it.live.skipIf(!process.env.CODEX_BINARY_PATH)(
231+
"keeps the same Codex provider thread across runtime mode switches",
232+
()=>
233+
withRealCodexHarness((harness)=>
234+
Effect.gen(function*(){
235+
constcreatedAt=nowIso();
236+
237+
yield*harness.engine.dispatch({
238+
type: "project.create",
239+
commandId: CommandId.makeUnsafe("cmd-project-create-real-codex"),
240+
projectId: PROJECT_ID,
241+
title: "Integration Project",
242+
workspaceRoot: harness.workspaceDir,
243+
defaultModel: "gpt-5.3-codex",
244+
createdAt,
245+
});
246+
247+
yield*harness.engine.dispatch({
248+
type: "thread.create",
249+
commandId: CommandId.makeUnsafe("cmd-thread-create-real-codex"),
250+
threadId: THREAD_ID,
251+
projectId: PROJECT_ID,
252+
title: "Integration Thread",
253+
model: "gpt-5.3-codex",
254+
runtimeMode: "full-access",
255+
branch: null,
256+
worktreePath: harness.workspaceDir,
257+
createdAt,
258+
});
259+
260+
yield*harness.engine.dispatch({
261+
type: "thread.turn.start",
262+
commandId: CommandId.makeUnsafe("cmd-turn-start-real-codex-1"),
263+
threadId: THREAD_ID,
264+
message: {
265+
messageId: asMessageId("msg-real-codex-1"),
266+
role: "user",
267+
text: "Reply with exactly ALPHA.",
268+
attachments: [],
269+
},
270+
runtimeMode: "full-access",
271+
createdAt: nowIso(),
272+
});
273+
274+
constfirstThread=yield*harness.waitForThread(
275+
THREAD_ID,
276+
(entry)=>
277+
entry.session?.status==="ready"&&
278+
entry.session.providerName==="codex"&&
279+
entry.session.providerThreadId!==null&&
280+
entry.messages.some(
281+
(message)=>message.role==="assistant"&&message.streaming===false,
282+
),
283+
180_000,
284+
);
285+
286+
constoriginalProviderThreadId=firstThread.session?.providerThreadId;
287+
assert.isNotNull(originalProviderThreadId);
288+
289+
yield*harness.engine.dispatch({
290+
type: "thread.turn.start",
291+
commandId: CommandId.makeUnsafe("cmd-turn-start-real-codex-2"),
292+
threadId: THREAD_ID,
293+
message: {
294+
messageId: asMessageId("msg-real-codex-2"),
295+
role: "user",
296+
text: "Reply with exactly BETA.",
297+
attachments: [],
298+
},
299+
runtimeMode: "approval-required",
300+
createdAt: nowIso(),
301+
});
302+
303+
constsecondThread=yield*harness.waitForThread(
304+
THREAD_ID,
305+
(entry)=>
306+
entry.session?.status==="ready"&&
307+
entry.session.providerName==="codex"&&
308+
entry.session.providerThreadId!==null&&
309+
entry.session.runtimeMode==="approval-required"&&
310+
entry.messages.some(
311+
(message)=>message.role==="assistant"&&message.text.includes("BETA"),
312+
),
313+
180_000,
314+
);
315+
316+
assert.equal(secondThread.session?.providerThreadId,originalProviderThreadId);
317+
}),
318+
),
319+
);
320+
219321
it.live("runs multi-turn file edits and persists checkpoint diffs",()=>
220322
withHarness((harness)=>
221323
Effect.gen(function*(){

‎apps/server/integration/providerService.integration.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ it.effect("replays typed runtime fixture events", () =>
122122
{
123123
provider: "codex",
124124
cwd: fixture.cwd,
125+
runtimeMode: "full-access",
125126
},
126127
);
127128
assert.equal((session.threadId??"").length>0,true);
@@ -155,6 +156,7 @@ it.effect("replays file-changing fixture turn events", () =>
155156
{
156157
provider: "codex",
157158
cwd: fixture.cwd,
159+
runtimeMode: "full-access",
158160
},
159161
);
160162
assert.equal((session.threadId??"").length>0,true);
@@ -192,6 +194,7 @@ it.effect("runs multi-turn tool/approval flow", () =>
192194
{
193195
provider: "codex",
194196
cwd: fixture.cwd,
197+
runtimeMode: "full-access",
195198
},
196199
);
197200
assert.equal((session.threadId??"").length>0,true);
@@ -244,6 +247,7 @@ it.effect("rolls back provider conversation state only", () =>
244247
{
245248
provider: "codex",
246249
cwd: fixture.cwd,
250+
runtimeMode: "full-access",
247251
},
248252
);
249253
assert.equal((session.threadId??"").length>0,true);

‎apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ function makeSnapshot(input: {
4343
projectId: input.projectId,
4444
title: "Thread",
4545
model: "gpt-5-codex",
46+
runtimeMode: "full-access",
4647
branch: null,
4748
worktreePath: input.worktreePath,
4849
latestTurn: {

‎apps/server/src/codexAppServerManager.test.ts‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import{describe,expect,it,vi}from"vitest";
2+
import{randomUUID}from"node:crypto";
3+
import{mkdtempSync,rmSync,writeFileSync}from"node:fs";
4+
importosfrom"node:os";
5+
importpathfrom"node:path";
26
import{ProviderSessionId}from"@t3tools/contracts";
37

48
import{
@@ -165,6 +169,7 @@ describe("startSession", () => {
165169
awaitexpect(
166170
manager.startSession({
167171
provider: "codex",
172+
runtimeMode: "full-access",
168173
}),
169174
).rejects.toThrow("cwd missing");
170175
expect(events).toHaveLength(1);
@@ -347,3 +352,84 @@ describe("thread checkpoint control", () => {
347352
});
348353
});
349354
});
355+
356+
describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume",()=>{
357+
it(
358+
"keeps prior thread history when resuming with a changed runtime mode",
359+
async()=>{
360+
constworkspaceDir=mkdtempSync(path.join(os.tmpdir(),"codex-live-resume-"));
361+
writeFileSync(path.join(workspaceDir,"README.md"),"hello\n","utf8");
362+
363+
constmanager=newCodexAppServerManager();
364+
365+
try{
366+
constfirstSession=awaitmanager.startSession({
367+
provider: "codex",
368+
cwd: workspaceDir,
369+
runtimeMode: "full-access",
370+
providerOptions: {
371+
codex: {
372+
binaryPath: process.env.CODEX_BINARY_PATH,
373+
...(process.env.CODEX_HOME_PATH
374+
? {homePath: process.env.CODEX_HOME_PATH}
375+
: {}),
376+
},
377+
},
378+
});
379+
380+
constfirstTurn=awaitmanager.sendTurn({
381+
sessionId: firstSession.sessionId,
382+
input: `Reply with exactly the word ALPHA ${randomUUID()}`,
383+
});
384+
385+
expect(firstTurn.threadId).toBe(firstSession.threadId);
386+
387+
awaitvi.waitFor(async()=>{
388+
constsnapshot=awaitmanager.readThread(firstSession.sessionId);
389+
expect(snapshot.turns.length).toBeGreaterThan(0);
390+
},{timeout: 120_000,interval: 1_000});
391+
392+
constfirstSnapshot=awaitmanager.readThread(firstSession.sessionId);
393+
constoriginalThreadId=firstSnapshot.threadId;
394+
constoriginalTurnCount=firstSnapshot.turns.length;
395+
396+
manager.stopSession(firstSession.sessionId);
397+
398+
constresumedSession=awaitmanager.startSession({
399+
provider: "codex",
400+
cwd: workspaceDir,
401+
runtimeMode: "approval-required",
402+
resumeCursor: firstSession.resumeCursor,
403+
providerOptions: {
404+
codex: {
405+
binaryPath: process.env.CODEX_BINARY_PATH,
406+
...(process.env.CODEX_HOME_PATH
407+
? {homePath: process.env.CODEX_HOME_PATH}
408+
: {}),
409+
},
410+
},
411+
});
412+
413+
expect(resumedSession.threadId).toBe(originalThreadId);
414+
415+
constresumedSnapshotBeforeTurn=awaitmanager.readThread(resumedSession.sessionId);
416+
expect(resumedSnapshotBeforeTurn.threadId).toBe(originalThreadId);
417+
expect(resumedSnapshotBeforeTurn.turns.length).toBeGreaterThanOrEqual(originalTurnCount);
418+
419+
awaitmanager.sendTurn({
420+
sessionId: resumedSession.sessionId,
421+
input: `Reply with exactly the word BETA ${randomUUID()}`,
422+
});
423+
424+
awaitvi.waitFor(async()=>{
425+
constsnapshot=awaitmanager.readThread(resumedSession.sessionId);
426+
expect(snapshot.turns.length).toBeGreaterThan(originalTurnCount);
427+
},{timeout: 120_000,interval: 1_000});
428+
}finally{
429+
manager.stopAll();
430+
rmSync(workspaceDir,{recursive: true,force: true});
431+
}
432+
},
433+
180_000,
434+
);
435+
});

‎apps/server/src/codexAppServerManager.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
204204
sessionId,
205205
provider: "codex",
206206
status: "connecting",
207+
runtimeMode: input.runtimeMode,
207208
model: normalizeCodexModelSlug(input.model),
208209
cwd: resolvedCwd,
209210
createdAt: now,

0 commit comments

Comments
 (0)