Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions AGENTS.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cafecode/desktop",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cafeai/cafe-code",
"version": "0.1.0",
"version": "0.1.1",
"description": "A minimal AI chat harness for coding agents.",
"keywords": [
"agent",
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/provider/Layers/CodexAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2035,13 +2035,15 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
total: {
inputTokens: 11_833,
cachedInputTokens: 3456,
cacheWriteInputTokens: 77,
outputTokens: 6,
reasoningOutputTokens: 0,
totalTokens: 11_839,
},
last: {
inputTokens: 120,
cachedInputTokens: 0,
cacheWriteInputTokens: 12,
outputTokens: 6,
reasoningOutputTokens: 0,
totalTokens: 126,
Expand All @@ -2068,11 +2070,14 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
maxTokens: 258_400,
inputTokens: 120,
cachedInputTokens: 0,
cacheWriteInputTokens: 12,
totalCacheWriteInputTokens: 77,
outputTokens: 6,
reasoningOutputTokens: 0,
lastUsedTokens: 126,
lastInputTokens: 120,
lastCachedInputTokens: 0,
lastCacheWriteInputTokens: 12,
lastOutputTokens: 6,
lastReasoningOutputTokens: 0,
compactsAutomatically: true,
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/provider/Layers/CodexAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,8 @@ function normalizeCodexTokenUsage(
const maxTokens = usage.modelContextWindow ?? undefined;
const inputTokens = usage.last.inputTokens;
const cachedInputTokens = usage.last.cachedInputTokens;
const cacheWriteInputTokens = usage.last.cacheWriteInputTokens;
const totalCacheWriteInputTokens = usage.total.cacheWriteInputTokens;
const outputTokens = usage.last.outputTokens;
const reasoningOutputTokens = usage.last.reasoningOutputTokens;
const totalOutputTokens = usage.total.outputTokens;
Expand All @@ -514,11 +516,16 @@ function normalizeCodexTokenUsage(
...(maxTokens !== undefined ? { maxTokens } : {}),
...(inputTokens !== undefined ? { inputTokens } : {}),
...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}),
...(cacheWriteInputTokens !== undefined ? { cacheWriteInputTokens } : {}),
...(totalCacheWriteInputTokens !== undefined ? { totalCacheWriteInputTokens } : {}),
...(outputTokens !== undefined ? { outputTokens } : {}),
...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}),
...(usedTokens !== undefined ? { lastUsedTokens: usedTokens } : {}),
...(inputTokens !== undefined ? { lastInputTokens: inputTokens } : {}),
...(cachedInputTokens !== undefined ? { lastCachedInputTokens: cachedInputTokens } : {}),
...(cacheWriteInputTokens !== undefined
? { lastCacheWriteInputTokens: cacheWriteInputTokens }
: {}),
...(outputTokens !== undefined ? { lastOutputTokens: outputTokens } : {}),
...(reasoningOutputTokens !== undefined
? { lastReasoningOutputTokens: reasoningOutputTokens }
Expand Down
49 changes: 49 additions & 0 deletions apps/server/src/provider/Layers/CodexProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,18 @@ function mapGeneratedCredits(
};
}

function mapGeneratedSpendControlLimit(
limit: CodexSchema.V2GetAccountRateLimitsResponse__SpendControlLimitSnapshot | null | undefined,
): Exclude<ServerProviderAccountRateLimitSnapshot["individualLimit"], undefined> {
if (limit === null || limit === undefined) return null;
return {
limit: limit.limit,
remainingPercent: limit.remainingPercent,
resetsAt: limit.resetsAt,
used: limit.used,
};
}

function mapGeneratedRateLimitSnapshot(
snapshot: CodexSchema.V2GetAccountRateLimitsResponse__RateLimitSnapshot,
): ServerProviderAccountRateLimitSnapshot {
Expand All @@ -370,6 +382,12 @@ function mapGeneratedRateLimitSnapshot(
...(snapshot.rateLimitReachedType !== undefined
? { rateLimitReachedType: snapshot.rateLimitReachedType }
: {}),
...(snapshot.spendControlReached !== undefined
? { spendControlReached: snapshot.spendControlReached }
: {}),
...(snapshot.individualLimit !== undefined
? { individualLimit: mapGeneratedSpendControlLimit(snapshot.individualLimit) }
: {}),
...(snapshot.primary !== undefined
? { primary: mapGeneratedRateLimitWindow(snapshot.primary) }
: {}),
Expand Down Expand Up @@ -474,6 +492,22 @@ function mapRawCredits(value: unknown): ServerProviderAccountRateLimitSnapshot["
};
}

function mapRawSpendControlLimit(
value: unknown,
): ServerProviderAccountRateLimitSnapshot["individualLimit"] {
if (value === null) return null;
const record = readRecord(value);
if (!record) return undefined;
const limit = readTrimmedMetadata(record.limit);
const remainingPercent = readFiniteNumber(record.remaining_percent ?? record.remainingPercent);
const resetsAt = readNonNegativeInteger(record.resets_at ?? record.resetsAt);
const used = readTrimmedMetadata(record.used);
if (!limit || remainingPercent === undefined || resetsAt === undefined || !used) {
return undefined;
}
return { limit, remainingPercent, resetsAt, used };
}

function mapRawRateLimitReachedType(value: unknown): string | undefined {
const direct = readTrimmedMetadata(value);
if (direct) return direct;
Expand Down Expand Up @@ -548,17 +582,28 @@ function mapRawRateLimitSnapshot(input: {
readonly credits?: unknown;
readonly planType?: string | undefined;
readonly rateLimitReachedType?: string | undefined;
readonly spendControlReached?: unknown;
readonly individualLimit?: unknown;
}): ServerProviderAccountRateLimitSnapshot {
const rateLimit = readRecord(input.rateLimit);
const primary = mapRawRateLimitWindow(rateLimit?.primary_window ?? rateLimit?.primary);
const secondary = mapRawRateLimitWindow(rateLimit?.secondary_window ?? rateLimit?.secondary);
const credits = mapRawCredits(input.credits);
const rawSpendControlReached =
input.spendControlReached ?? rateLimit?.spend_control_reached ?? rateLimit?.spendControlReached;
const spendControlReached =
typeof rawSpendControlReached === "boolean" ? rawSpendControlReached : undefined;
const individualLimit = mapRawSpendControlLimit(
input.individualLimit ?? rateLimit?.individual_limit ?? rateLimit?.individualLimit,
);

return {
limitId: input.limitId,
...(input.limitName ? { limitName: input.limitName } : {}),
...(input.planType ? { planType: input.planType } : {}),
...(input.rateLimitReachedType ? { rateLimitReachedType: input.rateLimitReachedType } : {}),
...(spendControlReached !== undefined ? { spendControlReached } : {}),
...(individualLimit !== undefined ? { individualLimit } : {}),
...(primary ? { primary } : {}),
...(secondary ? { secondary } : {}),
...(credits ? { credits } : {}),
Expand All @@ -583,6 +628,8 @@ function parseCodexAccountRateLimitsPayload(
limitId: "codex",
rateLimit: record.rate_limit ?? record.rateLimit,
credits: record.credits,
spendControlReached: record.spend_control_reached ?? record.spendControlReached,
individualLimit: record.individual_limit ?? record.individualLimit,
...(planType ? { planType } : {}),
...(rateLimitReachedType ? { rateLimitReachedType } : {}),
});
Expand All @@ -604,6 +651,8 @@ function parseCodexAccountRateLimitsPayload(
limitId,
limitName: readTrimmedMetadata(additional.limit_name ?? additional.limitName),
rateLimit: additional.rate_limit ?? additional.rateLimit,
spendControlReached: additional.spend_control_reached ?? additional.spendControlReached,
individualLimit: additional.individual_limit ?? additional.individualLimit,
...(planType ? { planType } : {}),
});
}
Expand Down
17 changes: 17 additions & 0 deletions apps/server/src/provider/Layers/CodexSessionRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,23 @@ describe("Codex child conversation routing", () => {
suppressLifecycle: false,
},
);
assert.deepStrictEqual(
resolveCodexChildConversationNotification(
routes,
{
method: "thread/environment/disconnected",
params: {
threadId: "thread-child",
environmentId: "local",
},
},
"thread-parent",
),
{
parentTurnId,
suppressLifecycle: true,
},
);
});

it("keeps nested subagent output on the original visible parent turn", () => {
Expand Down
8 changes: 6 additions & 2 deletions apps/server/src/provider/Layers/CodexSessionRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1454,6 +1454,8 @@ function readNotificationThreadId(notification: CodexServerNotification): string
case "thread/name/updated":
case "thread/goal/updated":
case "thread/goal/cleared":
case "thread/environment/connected":
case "thread/environment/disconnected":
case "thread/settings/updated":
case "thread/tokenUsage/updated":
case "turn/started":
Expand Down Expand Up @@ -1548,8 +1550,8 @@ export function readCodexNotificationRouteFields(notification: CodexServerNotifi
itemId: readNotificationItemId(notification),
};
case "rawResponse/completed":
// Codex 0.145 alpha emits this only when `experimentalRawEvents` is
// enabled. Cafe intentionally leaves that stream disabled, but retain
// Codex 0.145 emits this only when `experimentalRawEvents` is enabled.
// Cafe intentionally leaves that stream disabled, but retain
// its native turn route if a future stable target enables it so exact
// response-usage telemetry can never leak across aggregate threads.
return {
Expand Down Expand Up @@ -1667,6 +1669,8 @@ function shouldSuppressChildConversationNotification(method: string): boolean {
method === "thread/deleted" ||
method === "thread/unarchived" ||
method === "thread/closed" ||
method === "thread/environment/connected" ||
method === "thread/environment/disconnected" ||
method === "thread/compacted" ||
method === "thread/name/updated" ||
method === "thread/tokenUsage/updated" ||
Expand Down
14 changes: 14 additions & 0 deletions apps/server/src/provider/Layers/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1005,7 +1005,7 @@
slashCommands: [],
skills: [],
} as const satisfies ServerProvider;
const makeInstance = (provider: ServerProvider): ProviderInstance => ({

Check warning on line 1008 in apps/server/src/provider/Layers/ProviderRegistry.test.ts

View workflow job for this annotation

GitHub Actions / Quality (ubuntu-24.04)

unicorn(consistent-function-scoping)

Function `makeInstance` does not capture any variables from its parent scope
instanceId: provider.instanceId,
driverKind: provider.driver,
continuationIdentity: {
Expand All @@ -1031,7 +1031,7 @@
const changes = yield* PubSub.unbounded<void>();
const instancesRef = yield* Ref.make<ReadonlyArray<ProviderInstance>>([codexInstance]);
const failNextList = yield* Ref.make(false);
const wait = () => Effect.yieldNow;

Check warning on line 1034 in apps/server/src/provider/Layers/ProviderRegistry.test.ts

View workflow job for this annotation

GitHub Actions / Quality (ubuntu-24.04)

unicorn(consistent-function-scoping)

Function `wait` does not capture any variables from its parent scope
const instanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, {
getInstance: (instanceId) =>
Ref.get(instancesRef).pipe(
Expand Down Expand Up @@ -1555,6 +1555,13 @@
unlimited: false,
balance: "9.99",
},
spend_control_reached: true,
individual_limit: {
limit: "100.00",
remaining_percent: 0,
resets_at: 1_780_200_000,
used: "100.00",
},
rate_limit_reset_credits: {
available_count: 2,
credits: [
Expand Down Expand Up @@ -1606,6 +1613,13 @@
assert.strictEqual(status.accountRateLimits?.rateLimits.planType, "pro");
assert.strictEqual(status.accountRateLimits?.rateLimits.primary?.windowDurationMins, 300);
assert.strictEqual(status.accountRateLimits?.rateLimits.secondary?.usedPercent, 75);
assert.strictEqual(status.accountRateLimits?.rateLimits.spendControlReached, true);
assert.deepStrictEqual(status.accountRateLimits?.rateLimits.individualLimit, {
limit: "100.00",
remainingPercent: 0,
resetsAt: 1_780_200_000,
used: "100.00",
});
assert.strictEqual(status.accountRateLimits?.rateLimitResetCredits?.availableCount, 2);
assert.deepStrictEqual(status.accountRateLimits?.rateLimitResetCredits?.credits, [
{
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cafecode/web",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
Expand Down
38 changes: 27 additions & 11 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
mergePendingSteerSnapshotsForInterruptedTurn,
resolveFollowUpQueuePhase,
resolveSendEnvMode,
shouldReplayCodexPendingSteerAfterTerminal,
shouldResolvePendingSteerDispatch,
shouldPinTimelineToEndForLocalMessage,
shouldWriteThreadErrorToCurrentServerThread,
waitForStartedServerThread,
Expand Down Expand Up @@ -114,41 +114,57 @@ describe("mergePendingSteerSnapshotsForInterruptedTurn", () => {
});
});

describe("shouldReplayCodexPendingSteerAfterTerminal", () => {
it("replays Codex steers when a turn ended before the steer entered provider items", () => {
describe("shouldResolvePendingSteerDispatch", () => {
it("keeps a Codex steer pending when only the previous turn became terminal", () => {
expect(
shouldReplayCodexPendingSteerAfterTerminal({
shouldResolvePendingSteerDispatch({
provider: "codex",
terminalTurnAfterSteer: true,
steerProcessingStarted: false,
steerFailureRecorded: false,
steerRecoveryRecorded: false,
assistantResponseAfterSteer: false,
}),
).toBe(true);
).toBe(false);
});

it("does not replay once Codex emitted the steer processing marker", () => {
it("resolves once Codex emits the steer processing marker", () => {
expect(
shouldReplayCodexPendingSteerAfterTerminal({
shouldResolvePendingSteerDispatch({
provider: "codex",
terminalTurnAfterSteer: true,
steerProcessingStarted: true,
steerFailureRecorded: false,
steerRecoveryRecorded: false,
assistantResponseAfterSteer: false,
}),
).toBe(false);
).toBe(true);
});

it("does not replay non-Codex terminal turns", () => {
it("resolves once the backend records Codex's no-active-turn recovery", () => {
expect(
shouldReplayCodexPendingSteerAfterTerminal({
shouldResolvePendingSteerDispatch({
provider: "codex",
terminalTurnAfterSteer: true,
steerProcessingStarted: false,
steerFailureRecorded: false,
steerRecoveryRecorded: true,
assistantResponseAfterSteer: false,
}),
).toBe(true);
});

it("preserves the existing terminal-resolution rule for non-Codex providers", () => {
expect(
shouldResolvePendingSteerDispatch({
provider: "claude",
terminalTurnAfterSteer: true,
steerProcessingStarted: false,
steerFailureRecorded: false,
steerRecoveryRecorded: false,
assistantResponseAfterSteer: false,
}),
).toBe(false);
).toBe(true);
});
});

Expand Down
25 changes: 19 additions & 6 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,19 +172,32 @@ export function mergePendingSteerSnapshotsForInterruptedTurn(
};
}

export function shouldReplayCodexPendingSteerAfterTerminal(input: {
export function shouldResolvePendingSteerDispatch(input: {
readonly provider: string | null | undefined;
readonly terminalTurnAfterSteer: boolean;
readonly steerProcessingStarted: boolean;
readonly steerFailureRecorded: boolean;
readonly steerRecoveryRecorded: boolean;
readonly assistantResponseAfterSteer: boolean;
}): boolean {
if (input.steerFailureRecorded || input.steerRecoveryRecorded) {
return true;
}

if (input.provider === "codex") {
// Upstream Codex owns the active-turn race inside one UserTurn command:
// `turn/steer` falls through to exactly one `turn/start` only after
// app-server reports that the cached active turn is gone. A terminal
// projection by itself is therefore not permission for the renderer to
// replay the same input. Wait for provider processing or the backend's
// explicit recovery/failure activity instead.
return input.steerProcessingStarted;
}

return (
input.provider === "codex" &&
input.terminalTurnAfterSteer &&
!input.steerProcessingStarted &&
!input.steerFailureRecorded &&
!input.steerRecoveryRecorded
input.steerProcessingStarted ||
input.assistantResponseAfterSteer ||
input.terminalTurnAfterSteer
);
}

Expand Down
Loading
Loading