Skip to content

Commit 5c8f523

Browse files
committed
Fix Codex session persistence and upstream sync
Fixes #4 Fixes #5 Fixes #6 Fixes #10
1 parent 0cbd92c commit 5c8f523

File tree

14 files changed

+263
-35
lines changed

14 files changed

+263
-35
lines changed

.github/workflows/codex-cli-watch.yml

Lines changed: 90 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,11 @@ jobs:
3232
set -euo pipefail
3333
3434
SUBMODULE_PATH="submodules/openai-codex"
35-
MODELS_FILE="codex-rs/core/models.json"
3635
CONFIG_SCHEMA_FILE="codex-rs/core/config.schema.json"
36+
MODEL_SOURCE_PATHS=(
37+
"codex-rs/models-manager/models.json"
38+
"codex-rs/core/models.json"
39+
)
3740
FLAG_SOURCE_PATHS=(
3841
"codex-rs/cli/src/main.rs"
3942
"codex-rs/exec/src/cli.rs"
@@ -46,6 +49,21 @@ jobs:
4649
git -C "$SUBMODULE_PATH" show "$sha:$path" 2>/dev/null || true
4750
}
4851
52+
read_first_existing_file_at_sha() {
53+
local sha="$1"
54+
shift
55+
local path
56+
57+
for path in "$@"; do
58+
if git -C "$SUBMODULE_PATH" cat-file -e "$sha:$path" 2>/dev/null; then
59+
read_file_at_sha "$sha" "$path"
60+
return 0
61+
fi
62+
done
63+
64+
return 0
65+
}
66+
4967
extract_flag_snapshot() {
5068
local sha="$1"
5169
local path
@@ -57,7 +75,7 @@ jobs:
5775
5876
extract_model_snapshot() {
5977
local sha="$1"
60-
read_file_at_sha "$sha" "$MODELS_FILE" \
78+
read_first_existing_file_at_sha "$sha" "${MODEL_SOURCE_PATHS[@]}" \
6179
| jq -r '.models[]? | (.id // .slug // empty)' \
6280
| sed '/^$/d' \
6381
| sort -u || true
@@ -250,7 +268,8 @@ jobs:
250268
per_page: 100,
251269
});
252270
253-
const duplicate = openIssues.find(issue =>
271+
const syncIssues = openIssues.filter(issue => !issue.pull_request);
272+
const duplicate = syncIssues.find(issue =>
254273
!issue.pull_request && issue.body && issue.body.includes(marker)
255274
);
256275
@@ -259,6 +278,11 @@ jobs:
259278
return;
260279
}
261280
281+
const reusableIssue = [...syncIssues]
282+
.sort((left, right) => {
283+
return new Date(right.updated_at).getTime() - new Date(left.updated_at).getTime();
284+
})[0];
285+
262286
const title = `Sync Codex CLI upstream changes (${shortCurrent} -> ${shortLatest})`;
263287
const body = [
264288
marker,
@@ -289,6 +313,7 @@ jobs:
289313
'',
290314
'## Action required',
291315
'- [ ] Validate latest `codex --help` and `codex exec --help` output',
316+
'- [ ] Validate latest `codex features list` output',
292317
'- [ ] Sync C# SDK constants/options/models with upstream CLI changes',
293318
'- [ ] Add or update tests for new flags/models/features',
294319
'- [ ] Update docs (README + docs/Features + docs/Architecture if needed)',
@@ -297,31 +322,80 @@ jobs:
297322
].join('\n');
298323
299324
let issue;
300-
try {
301-
issue = await github.rest.issues.create({
325+
if (reusableIssue) {
326+
issue = await github.rest.issues.update({
302327
owner: context.repo.owner,
303328
repo: context.repo.repo,
329+
issue_number: reusableIssue.number,
304330
title,
305331
body,
306332
labels: [labelName],
307-
assignees: [assignee],
308333
});
309-
core.info(`Created and assigned issue #${issue.data.number} to @${assignee}`);
310-
} catch (error) {
311-
core.warning(`Issue assignment failed (${error.message}), creating without assignee.`);
312-
issue = await github.rest.issues.create({
334+
335+
try {
336+
await github.rest.issues.addAssignees({
337+
owner: context.repo.owner,
338+
repo: context.repo.repo,
339+
issue_number: reusableIssue.number,
340+
assignees: [assignee],
341+
});
342+
core.info(`Updated existing sync issue #${reusableIssue.number} and assigned @${assignee}`);
343+
} catch (error) {
344+
core.warning(`Issue assignment failed (${error.message}) for #${reusableIssue.number}.`);
345+
await github.rest.issues.createComment({
346+
owner: context.repo.owner,
347+
repo: context.repo.repo,
348+
issue_number: reusableIssue.number,
349+
body: `Could not auto-assign @${assignee}. Please assign manually.`,
350+
});
351+
}
352+
} else {
353+
try {
354+
issue = await github.rest.issues.create({
355+
owner: context.repo.owner,
356+
repo: context.repo.repo,
357+
title,
358+
body,
359+
labels: [labelName],
360+
assignees: [assignee],
361+
});
362+
core.info(`Created and assigned issue #${issue.data.number} to @${assignee}`);
363+
} catch (error) {
364+
core.warning(`Issue assignment failed (${error.message}), creating without assignee.`);
365+
issue = await github.rest.issues.create({
366+
owner: context.repo.owner,
367+
repo: context.repo.repo,
368+
title,
369+
body,
370+
labels: [labelName],
371+
});
372+
373+
await github.rest.issues.createComment({
374+
owner: context.repo.owner,
375+
repo: context.repo.repo,
376+
issue_number: issue.data.number,
377+
body: `Could not auto-assign @${assignee}. Please assign manually.`,
378+
});
379+
}
380+
}
381+
382+
for (const staleIssue of syncIssues) {
383+
if (staleIssue.number === issue.data.number) {
384+
continue;
385+
}
386+
387+
await github.rest.issues.createComment({
313388
owner: context.repo.owner,
314389
repo: context.repo.repo,
315-
title,
316-
body,
317-
labels: [labelName],
390+
issue_number: staleIssue.number,
391+
body: `Superseded by #${issue.data.number} for newer upstream Codex CLI commit \`${shortLatest}\`.`,
318392
});
319393
320-
await github.rest.issues.createComment({
394+
await github.rest.issues.update({
321395
owner: context.repo.owner,
322396
repo: context.repo.repo,
323-
issue_number: issue.data.number,
324-
body: `Could not auto-assign @${assignee}. Please assign manually.`,
397+
issue_number: staleIssue.number,
398+
state: 'closed',
325399
});
326400
}
327401

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ If no new rule is detected -> do not update the file.
9090
- Do not keep or add public sample projects; repository focus is SDK + tests only.
9191
- Upstream sync automation must track real `openai/codex` CLI changes (flags/models/features), not TypeScript SDK surface diffs, and open actionable repository issues for required SDK follow-up.
9292
- Automatically opened upstream sync issues must include change summary/checklist and assign Copilot by default.
93-
- For `openai/codex` repo sync/update work, always inspect `submodules/openai-codex/codex-rs/core/models.json` and reconcile SDK model constants against that bundled catalog because it is the repo-authoritative model source.
93+
- For `openai/codex` repo sync/update work, always inspect the bundled `models.json` catalog in `submodules/openai-codex` (prefer `codex-rs/models-manager/models.json`, fall back to `codex-rs/core/models.json` for older pins) and reconcile SDK model constants against that repo-authoritative source.
9494
- At the end of implementation/code-change tasks, create a git commit unless the user explicitly says not to, so the workspace ends in a reviewable state.
9595
- Run verification in this order:
9696
- focused tests for changed behavior

CodexSharpSDK.Tests/Integration/RealCodexIntegrationTests.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
using System.Text.Json.Nodes;
12
using ManagedCode.CodexSharpSDK.Client;
3+
using ManagedCode.CodexSharpSDK.Configuration;
24
using ManagedCode.CodexSharpSDK.Models;
35
using ManagedCode.CodexSharpSDK.Tests.Shared;
46

@@ -90,6 +92,43 @@ public async Task RunAsync_WithRealCodexCli_SecondTurnKeepsThreadId()
9092
await Assert.That(thread.Id).IsEqualTo(firstThreadId);
9193
}
9294

95+
[Test]
96+
public async Task RunAsync_WithExplicitNonEphemeralOverride_PersistsRolloutWhenClientConfigEnablesEphemeral()
97+
{
98+
var settings = RealCodexTestSupport.GetRequiredSettings();
99+
100+
using var client = RealCodexTestSupport.CreateClient(new CodexOptions
101+
{
102+
Config = new JsonObject
103+
{
104+
["ephemeral"] = true,
105+
},
106+
});
107+
var thread = client.StartThread(new ThreadOptions
108+
{
109+
Model = settings.Model,
110+
ModelReasoningEffort = ModelReasoningEffort.Medium,
111+
WebSearchMode = WebSearchMode.Disabled,
112+
SandboxMode = SandboxMode.WorkspaceWrite,
113+
NetworkAccessEnabled = true,
114+
Ephemeral = false,
115+
});
116+
using var cancellation = new CancellationTokenSource(TimeSpan.FromMinutes(2));
117+
118+
var result = await thread.RunAsync(
119+
"Reply with short plain text: ok.",
120+
new TurnOptions { CancellationToken = cancellation.Token });
121+
122+
await Assert.That(result.Usage).IsNotNull();
123+
await Assert.That(thread.Id).IsNotNull();
124+
125+
var rolloutPath = await RealCodexTestSupport.FindPersistedRolloutPathAsync(
126+
thread.Id!,
127+
TimeSpan.FromSeconds(10));
128+
129+
await Assert.That(rolloutPath).IsNotNull();
130+
}
131+
93132
private static CodexThread StartRealIntegrationThread(CodexClient client, string model)
94133
{
95134
return client.StartThread(new ThreadOptions

CodexSharpSDK.Tests/Shared/RealCodexTestSupport.cs

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Diagnostics;
12
using ManagedCode.CodexSharpSDK.Client;
23
using ManagedCode.CodexSharpSDK.Configuration;
34
using ManagedCode.CodexSharpSDK.Internal;
@@ -19,9 +20,36 @@ public static RealCodexTestSettings GetRequiredSettings()
1920
return new RealCodexTestSettings(ResolveModel());
2021
}
2122

22-
public static CodexClient CreateClient()
23+
public static CodexClient CreateClient(CodexOptions? options = null)
2324
{
24-
return new CodexClient(new CodexOptions());
25+
return new CodexClient(options ?? new CodexOptions());
26+
}
27+
28+
public static async Task<string?> FindPersistedRolloutPathAsync(string threadId, TimeSpan timeout)
29+
{
30+
ArgumentException.ThrowIfNullOrWhiteSpace(threadId);
31+
32+
var sessionsPath = GetCodexSessionsPath();
33+
if (sessionsPath is null || !Directory.Exists(sessionsPath))
34+
{
35+
return null;
36+
}
37+
38+
var stopwatch = Stopwatch.StartNew();
39+
while (stopwatch.Elapsed < timeout)
40+
{
41+
var rolloutPath = Directory
42+
.EnumerateFiles(sessionsPath, $"rollout-*{threadId}.jsonl", SearchOption.AllDirectories)
43+
.FirstOrDefault();
44+
if (!string.IsNullOrWhiteSpace(rolloutPath))
45+
{
46+
return rolloutPath;
47+
}
48+
49+
await Task.Delay(TimeSpan.FromMilliseconds(200)).ConfigureAwait(false);
50+
}
51+
52+
return null;
2553
}
2654

2755
private static string ResolveModel()
@@ -108,6 +136,17 @@ private static string ResolveModel()
108136
return Path.Combine(homeDirectory, ".codex", "config.toml");
109137
}
110138

139+
private static string? GetCodexSessionsPath()
140+
{
141+
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
142+
if (string.IsNullOrWhiteSpace(homeDirectory))
143+
{
144+
return null;
145+
}
146+
147+
return Path.Combine(homeDirectory, ".codex", "sessions");
148+
}
149+
111150
private static bool IsCodexAvailable()
112151
{
113152
var resolvedPath = CodexCliLocator.FindCodexPath(null);

CodexSharpSDK.Tests/Unit/CodexExecTests.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,28 @@ public async Task BuildCommandArgs_MapsExtendedCliFlags()
151151
await Assert.That(commandArgs.Contains("custom-value")).IsTrue();
152152
}
153153

154+
[Test]
155+
public async Task BuildCommandArgs_ExplicitFalseEphemeral_OverridesConfigPersistence()
156+
{
157+
var exec = new CodexExec(
158+
executablePath: "codex",
159+
environmentOverride: null,
160+
configOverrides: new JsonObject
161+
{
162+
["ephemeral"] = true,
163+
});
164+
165+
var commandArgs = exec.BuildCommandArgs(new CodexExecArgs
166+
{
167+
Input = "test",
168+
Ephemeral = false,
169+
});
170+
171+
await Assert.That(commandArgs.Contains("--ephemeral")).IsFalse();
172+
await Assert.That(CollectConfigValues(commandArgs, "ephemeral"))
173+
.IsEquivalentTo(["ephemeral=true", "ephemeral=false"]);
174+
}
175+
154176
[Test]
155177
public async Task BuildCommandArgs_KeepsConfiguredWebSearchWhenThreadOverridesMissing()
156178
{

CodexSharpSDK.Tests/Unit/CodexModelsTests.cs

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ public class CodexModelsTests
88
{
99
private const string SolutionFileName = "ManagedCode.CodexSharpSDK.slnx";
1010
private const string BundledModelsFileName = "models.json";
11+
private static readonly string[] BundledModelsRelativePaths =
12+
[
13+
Path.Combine("submodules", "openai-codex", "codex-rs", "models-manager", BundledModelsFileName),
14+
Path.Combine("submodules", "openai-codex", "codex-rs", "core", BundledModelsFileName),
15+
];
1116

1217
[Test]
1318
public async Task CodexModels_ContainAllBundledUpstreamModelSlugs()
@@ -17,8 +22,12 @@ public async Task CodexModels_ContainAllBundledUpstreamModelSlugs()
1722
var missingBundledSlugs = bundledModelSlugs
1823
.Except(sdkModelSlugs, StringComparer.Ordinal)
1924
.ToArray();
25+
var extraSdkSlugs = sdkModelSlugs
26+
.Except(bundledModelSlugs, StringComparer.Ordinal)
27+
.ToArray();
2028

2129
await Assert.That(missingBundledSlugs).IsEmpty();
30+
await Assert.That(extraSdkSlugs).IsEmpty();
2231
}
2332

2433
private static string[] GetSdkModelSlugs()
@@ -46,13 +55,17 @@ private static async Task<string[]> ReadBundledModelSlugsAsync()
4655

4756
private static string ResolveBundledModelsFilePath()
4857
{
49-
return Path.Combine(
50-
ResolveRepositoryRootPath(),
51-
"submodules",
52-
"openai-codex",
53-
"codex-rs",
54-
"core",
55-
BundledModelsFileName);
58+
var repositoryRootPath = ResolveRepositoryRootPath();
59+
foreach (var relativePath in BundledModelsRelativePaths)
60+
{
61+
var candidatePath = Path.Combine(repositoryRootPath, relativePath);
62+
if (File.Exists(candidatePath))
63+
{
64+
return candidatePath;
65+
}
66+
}
67+
68+
throw new InvalidOperationException("Could not locate bundled upstream models.json under the openai-codex submodule.");
5669
}
5770

5871
private static string ResolveRepositoryRootPath()

0 commit comments

Comments
 (0)