Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
2f16339
Support scoped quota data and consistent usage status
TheBeems Sep 9, 2026
c573da0
Link reliability sprint to pull request 18
TheBeems Sep 9, 2026
e742b21
Add quiet alerts, account activity, and Dock source controls
TheBeems Sep 9, 2026
4c2bd15
Link sprint 2 changes to pull request 19
TheBeems Sep 9, 2026
d067295
Use fresh observations in reset expiry notification regression test
TheBeems Sep 9, 2026
1ee38a9
Add retained history, workday planning, and explicit account actions
TheBeems Sep 9, 2026
ebe7efe
Link sprint 3 features to pull request 20
TheBeems Sep 9, 2026
9b507f6
Add local source profiles and an optional Claude usage pilot
TheBeems Sep 9, 2026
e07db18
Link sprint 4 to its review and verification record
TheBeems Sep 9, 2026
3c50c02
fix: preserve Dock bands and respect the active display mode
TheBeems Sep 9, 2026
36363df
docs: record Dock mode fix and regression coverage
TheBeems Sep 9, 2026
188b267
feat: show reset and expiry dates in Dock subtitles
TheBeems Sep 9, 2026
cb82f54
docs: record Dock date subtitles
TheBeems Sep 9, 2026
7c63ff2
fix: calculate planner budgets in the local time zone
TheBeems Sep 10, 2026
4fe022d
Merge branch 'codex/sprint-3-history-planning' into codex/sprint-4-pr…
TheBeems Sep 10, 2026
6014fd9
fix: tolerate inaccessible sessions and refresh Claude interval status
TheBeems Sep 10, 2026
a281cfa
Merge branch 'codex/sprint-4-provider-pilot' into codex/fix-dock-mode…
TheBeems Sep 10, 2026
4a447ce
Merge branch 'codex/fix-dock-mode-switch' into codex/dock-reset-date-…
TheBeems Sep 10, 2026
df4d18f
fix: use regional calendar days in Dock subtitles
TheBeems Sep 10, 2026
39bf74c
Remove Claude integration and manual source settings
TheBeems Sep 13, 2026
32f2ba5
Pin Codex-only changelog entry to implementation commit
TheBeems Sep 13, 2026
263c22a
Remove workday planner and its settings
TheBeems Sep 13, 2026
c8d1785
Pin planner removal changelog to implementation commit
TheBeems Sep 13, 2026
b9f9383
Remove obsolete unreleased planner changelog entries
TheBeems Sep 13, 2026
945cf79
Merge pull request #24 from TheBeems/codex/codex-only-settings
TheBeems Sep 13, 2026
c18219e
Merge pull request #23 from TheBeems/codex/dock-reset-date-subtitles
TheBeems Sep 13, 2026
9411220
Merge pull request #22 from TheBeems/codex/fix-dock-mode-switch
TheBeems Sep 13, 2026
e8749c7
Merge pull request #21 from TheBeems/codex/sprint-4-provider-pilot
TheBeems Sep 13, 2026
ddcbf77
Merge pull request #20 from TheBeems/codex/sprint-3-history-planning
TheBeems Sep 13, 2026
3541ad2
Merge pull request #19 from TheBeems/codex/sprint-2-attention-controls
TheBeems Sep 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,36 @@ Each entry links to the commit or pull request that introduced the change.

## [Unreleased]

### Added

- A text alternative for Codex quota, reset, trend, and local token data. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21))
- Optional account-scoped quota history with 7/30/90-day retention, explicit CSV/JSON exports, and confirmed deletion. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20))
- Task-level server usage estimates and explicitly confirmed earned resets with account verification and persistent request IDs for safe retries. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20))
- Optional quiet usage alerts from fresh, identified accounts, compact Dock labels, and separate pinnable quota and credit entries with stable identifiers. ([PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19))
- Account-wide daily token activity on compatible Codex versions, with independent refresh and account-identity verification. ([PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19))
- Separate quota categories and arbitrary window durations from modern Codex responses, while preserving legacy five-hour and weekly limits. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18))
- Safe diagnostics with running-build version, source, freshness, refresh attempts, and reset-field availability. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18))

### Fixed

- Use the regional calendar consistently for the day and month in Dock reset and expiry dates. ([PR #23](https://github.com/TheBeems/CodexUsageDock/pull/23))
- Switching Dock modes or hiding a metric no longer restores inactive saved bands. Existing band objects are retained, and usage refreshes update their items without reloading the whole provider. ([PR #22](https://github.com/TheBeems/CodexUsageDock/pull/22))
- Skip inaccessible session subdirectories during local fallback discovery. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21))
- Serialize source-sensitive presentation changes so delayed updates cannot restore old account values. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21))
- Keep the last confirmed live measurement during outages, without resetting its age or continuing projections and learning. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18))
- Apply one freshness policy across the Dock, details, and forecasts, and keep account/category history isolated. Unidentified legacy history is no longer imported into verified accounts. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18))

### Changed

- Show the local reset date and next earned-reset expiry directly in fresh Dock subtitles, with short regional month names and minute-precise times. ([PR #23](https://github.com/TheBeems/CodexUsageDock/pull/23))
- Cache local quota fallback read positions with bounded content reads and memory, while reporting incomplete scans and preserving event-time selection. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21))
- Expanded the release skill to cover scoped commit/push, Store submission, resumable certification tracking, and verified installation, with a repository-local Codex entry point. ([commit e54709c](https://github.com/TheBeems/CodexUsageDock/commit/e54709ce6e26b9aaa072d6f88625a9a3aa067494))
- Distinguish source releases, the running extension build, and Microsoft Store rollout in installation guidance. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18))

### Removed

- Remove the workday planner and its workday-end and remaining-workdays settings. Existing planner preferences are ignored and dropped on the next settings save; usage displays, reset times, forecasts, and history remain available. ([commit 263c22a](https://github.com/TheBeems/CodexUsageDock/commit/263c22a650f2b7062515f94e983023e337dc7610))
- Remove the experimental Claude integration and capture script, manual Codex path settings, and source profiles to keep the extension focused on automatically detected Codex usage. Older source preferences are ignored while other saved Codex choices are preserved. ([commit 39bf74c](https://github.com/TheBeems/CodexUsageDock/commit/39bf74cd476920441146f37737ad1216a9c0b8ad))

## [0.6.1] - 2026-09-09

Expand Down
277 changes: 277 additions & 0 deletions CodexUsageDock.Tests/AccountUsageTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
using System.Text.Json;
using Xunit;

namespace CodexUsageDock.Tests;

public sealed class AccountUsageTests
{
private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero);

[Fact]
public void ParsesServerCalendarDaysAndNullableSummaryWithoutInventingValues()
{
var result = Parse("""
{
"summary": {
"lifetimeTokens": 123456,
"peakDailyTokens": 0,
"longestRunningTurnSec": 3600,
"currentStreakDays": null,
"longestStreakDays": 12
},
"dailyUsageBuckets": [
{ "startDate": "2026-09-09", "tokens": 2000 },
{ "startDate": "2026-09-07", "tokens": 0 }
]
}
""");

Assert.Equal(AccountUsageStatus.Available, result.Status);
Assert.Equal("synthetic-hash", result.AccountKey);
Assert.Equal(Now, result.UpdatedAt);
Assert.Equal(123456, result.LifetimeTokens);
Assert.Equal(0, result.PeakDailyTokens);
Assert.Equal(3600, result.LongestRunningTurnSeconds);
Assert.Null(result.CurrentStreakDays);
Assert.Equal(12, result.LongestStreakDays);
Assert.Collection(result.Days,
day => Assert.Equal(new DailyTokenUsage(new DateOnly(2026, 9, 7), 0), day),
day => Assert.Equal(new DailyTokenUsage(new DateOnly(2026, 9, 9), 2000), day));
}

[Fact]
public void InvalidDatesNegativeTokensOverflowAndDuplicatesYieldPartialData()
{
var result = Parse("""
{
"summary": { "lifetimeTokens": -1, "peakDailyTokens": 9223372036854775808, "currentStreakDays": "10" },
"dailyUsageBuckets": [
{ "startDate": "2026-09-09", "tokens": 100 },
{ "startDate": "2026-09-09", "tokens": 200 },
{ "startDate": "2026-02-30", "tokens": 100 },
{ "startDate": "2026-9-8", "tokens": 100 },
{ "startDate": "2026-09-08T00:00:00Z", "tokens": 100 },
{ "startDate": "2026-09-08", "tokens": -1 },
{ "startDate": "2026-09-07", "tokens": 9223372036854775808 },
{ "startDate": "2026-09-06", "tokens": "100" },
null
]
}
""");

Assert.Equal(AccountUsageStatus.Partial, result.Status);
Assert.Equal(100, Assert.Single(result.Days).TotalTokens);
Assert.Null(result.LifetimeTokens);
Assert.Null(result.PeakDailyTokens);
Assert.Null(result.CurrentStreakDays);
}

[Fact]
public void RetainsAtMost366LatestServerDaysAndMarksTruncation()
{
var start = new DateOnly(2024, 1, 1);
var daily = Enumerable.Range(0, 370).Select(index => new
{
startDate = start.AddDays(index).ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture),
tokens = index,
});

var result = Parse(JsonSerializer.Serialize(new { dailyUsageBuckets = daily }));

Assert.Equal(AccountUsageStatus.Partial, result.Status);
Assert.Equal(AccountUsageParser.MaximumDays, result.Days.Count);
Assert.Equal(start.AddDays(4), result.Days[0].Date);
Assert.Equal(start.AddDays(369), result.Days[^1].Date);
}

[Theory]
[InlineData("{}")]
[InlineData("null")]
[InlineData("[]")]
[InlineData("{\"summary\":{\"lifetimeTokens\":null},\"dailyUsageBuckets\":null}")]
public void MissingDataIsUnavailableRatherThanZero(string json)
{
var result = Parse(json);

Assert.Equal(AccountUsageStatus.Unavailable, result.Status);
Assert.Empty(result.Days);
Assert.Null(result.LifetimeTokens);
Assert.Null(result.AccountKey);
}

[Fact]
public void SummaryWithoutDailyDataIsPartialAndConfirmedEmptyRowsRemainAvailable()
{
var summaryOnly = Parse("""{"summary":{"lifetimeTokens":0},"dailyUsageBuckets":null}""");
var emptyDays = Parse("""{"summary":{"lifetimeTokens":null},"dailyUsageBuckets":[]}""");

Assert.Equal(AccountUsageStatus.Partial, summaryOnly.Status);
Assert.Equal(0, summaryOnly.LifetimeTokens);
Assert.Equal(AccountUsageStatus.Available, emptyDays.Status);
Assert.Empty(emptyDays.Days);
Assert.Null(emptyDays.LifetimeTokens);
}

[Fact]
public async Task ActivityIsReadBetweenMatchingAccountChecks()
{
var requests = new List<(string Method, int Id)>();
var result = await CodexAppServerReader.ReadAccountUsageSequenceAsync((method, id, _) =>
{
requests.Add((method, id));
return Task.FromResult(JsonDocument.Parse(method == "account/usage/read"
? """{"result":{"dailyUsageBuckets":[{"startDate":"2026-09-09","tokens":100}]}}"""
: """{"result":{"accountId":"synthetic-account"}}"""));
});

Assert.Equal(new[] { ("account/rateLimits/read", 2), ("account/usage/read", 3), ("account/rateLimits/read", 4) }, requests);
Assert.Equal(AccountUsageStatus.Available, result.Status);
Assert.Equal(100, Assert.Single(result.Days).TotalTokens);
Assert.Equal(64, result.AccountKey!.Length);
Assert.DoesNotContain("synthetic-account", JsonSerializer.Serialize(result), StringComparison.Ordinal);
}

[Fact]
public async Task AccountChangeDiscardsEveryUsageValue()
{
var result = await CodexAppServerReader.ReadAccountUsageSequenceAsync((method, id, _) =>
Task.FromResult(JsonDocument.Parse(id switch
{
2 => """{"result":{"accountId":"synthetic-account-A"}}""",
3 => """{"result":{"summary":{"lifetimeTokens":123},"dailyUsageBuckets":[{"startDate":"2026-09-09","tokens":100}]}}""",
_ => """{"result":{"accountId":"synthetic-account-B"}}""",
})));

Assert.Equal(AccountUsageStatus.Unavailable, result.Status);
Assert.Empty(result.Days);
Assert.Null(result.AccountKey);
Assert.Null(result.LifetimeTokens);
}

[Fact]
public async Task MissingInitialIdentitySkipsTheAccountUsageRequest()
{
var count = 0;
var result = await CodexAppServerReader.ReadAccountUsageSequenceAsync((_, _, _) =>
{
count++;
return Task.FromResult(JsonDocument.Parse("""{"result":{"accountId":null}}"""));
});

Assert.Equal(1, count);
Assert.Equal(AccountUsageStatus.Unavailable, result.Status);
}

[Theory]
[InlineData("{\"result\":{\"accountId\":null}}")]
[InlineData("{\"error\":{\"code\":-1,\"message\":\"private details\"}}")]
public async Task MissingFinalIdentityDiscardsActivity(string finalResponse)
{
var result = await CodexAppServerReader.ReadAccountUsageSequenceAsync((_, id, _) =>
Task.FromResult(JsonDocument.Parse(id switch
{
2 => """{"result":{"accountId":"synthetic-account"}}""",
3 => """{"result":{"dailyUsageBuckets":[]}}""",
_ => finalResponse,
})));

Assert.Equal(AccountUsageStatus.Unavailable, result.Status);
Assert.Null(result.AccountKey);
Assert.DoesNotContain("private", JsonSerializer.Serialize(result), StringComparison.Ordinal);
}

[Fact]
public async Task MethodNotFoundIsUnsupportedWithoutReadingOrExposingErrorText()
{
var requests = 0;
var result = await CodexAppServerReader.ReadAccountUsageSequenceAsync((_, id, _) =>
{
requests++;
return Task.FromResult(JsonDocument.Parse(id == 2
? """{"result":{"accountId":"synthetic-account"}}"""
: """{"error":{"code":-32601,"message":"private path or credential"}}"""));
});

Assert.Equal(AccountUsageStatus.Unsupported, result.Status);
Assert.Equal(2, requests);
Assert.Empty(result.Days);
Assert.DoesNotContain("private", JsonSerializer.Serialize(result), StringComparison.Ordinal);
}

[Fact]
public async Task PreCanceledActivityReadDoesNotStartARequest()
{
await Assert.ThrowsAsync<OperationCanceledException>(() =>
CodexAppServerReader.ReadAccountUsageSequenceAsync((_, _, _) => throw new InvalidOperationException("Must not run"),
new CancellationToken(canceled: true)));
}

[Fact]
public async Task ResponseReaderIgnoresUnrelatedMalformedIdentifiers()
{
using var reader = new StringReader("""
[]
{"id":"2","result":{}}
{"id":2,"result":{}}
""");

var responses = await CodexAppServerReader.ReadResponsesAsync(reader, CancellationToken.None, 2);
using var response = responses[2];
Assert.Equal(2, response.RootElement.GetProperty("id").GetInt32());
}

[Fact]
public async Task ResponseReaderRejectsOversizedMessagesWithABoundedError()
{
using var reader = new StringReader(new string('x', 1_048_577));

var error = await Assert.ThrowsAsync<InvalidOperationException>(() =>
CodexAppServerReader.ReadResponsesAsync(reader, CancellationToken.None, 2));

Assert.Equal("Codex app-server returned an oversized response.", error.Message);
}

[Fact]
public void ConfiguredSourceUsesProcessArgumentsAndAnEnvironmentVariable()
{
var executable = Path.Combine(Path.GetTempPath(), "synthetic cli", "codex.exe");
var home = Path.Combine(Path.GetTempPath(), "synthetic profile & account");

var info = CodexAppServerReader.CreateStartInfo(new CodexSourceOptions(executable, home));

Assert.Equal(executable, info.FileName);
Assert.Collection(info.ArgumentList,
argument => Assert.Equal("app-server", argument),
argument => Assert.Equal("--stdio", argument));
Assert.Equal(home, info.Environment["CODEX_HOME"]);
Assert.Equal(Path.GetDirectoryName(executable), info.WorkingDirectory);
Assert.False(info.UseShellExecute);
Assert.True(info.CreateNoWindow);
Assert.Empty(info.Arguments);
}

[Fact]
public void ActivityPageShowsAtMost30ServerDatesAndNeverAccountIdentifiers()
{
var start = new DateOnly(2026, 8, 1);
var snapshot = new AccountUsageSnapshot("sensitive-account-key", Now,
Enumerable.Range(0, 40).Select(index => new DailyTokenUsage(start.AddDays(index), index)).ToArray(),
AccountUsageStatus.Partial, LifetimeTokens: 0);

var text = CodexAccountActivityPage.FormatActivity(snapshot);

Assert.Contains("Partial data", text, StringComparison.Ordinal);
Assert.Contains("Their time zone is not specified", text, StringComparison.Ordinal);
Assert.Contains("**Lifetime tokens:** 0", text, StringComparison.Ordinal);
Assert.Contains("**Peak daily tokens:** Not reported", text, StringComparison.Ordinal);
Assert.Equal(30, text.Split('\n').Count(line => line.StartsWith("| 2026-", StringComparison.Ordinal)));
Assert.DoesNotContain("sensitive-account-key", text, StringComparison.Ordinal);
Assert.DoesNotContain("| 2026-08-01", text, StringComparison.Ordinal);
}

private static AccountUsageSnapshot Parse(string json)
{
using var document = JsonDocument.Parse(json);
return AccountUsageParser.Parse(document.RootElement, "synthetic-hash", Now);
}
}
Loading