Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
28 changes: 20 additions & 8 deletions apps/server/src/codexAppServerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [
const CODEX_DEFAULT_MODEL = "gpt-5.3-codex";
const CODEX_SPARK_MODEL = "gpt-5.3-codex-spark";
const CODEX_SPARK_DISABLED_PLAN_TYPES = new Set<CodexPlanType>(["free", "go", "plus"]);
const CODEX_LONG_RUNNING_TOOL_TIMEOUT_SEC = 315_360_000;
const CODEX_LONG_RUNNING_STREAM_IDLE_TIMEOUT_MS = 315_360_000_000;

function asObject(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object") {
Expand Down Expand Up @@ -548,15 +550,25 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
cwd: resolvedCwd,
...(codexHomePath ? { homePath: codexHomePath } : {}),
});
const child = spawn(codexBinaryPath, ["app-server"], {
cwd: resolvedCwd,
env: {
...process.env,
...(codexHomePath ? { CODEX_HOME: codexHomePath } : {}),
const child = spawn(
codexBinaryPath,
[
"app-server",
"-c",
`tool_timeout_sec=${CODEX_LONG_RUNNING_TOOL_TIMEOUT_SEC}`,
"-c",
`model_providers.openai.stream_idle_timeout_ms=${CODEX_LONG_RUNNING_STREAM_IDLE_TIMEOUT_MS}`,
],
{
cwd: resolvedCwd,
env: {
...process.env,
...(codexHomePath ? { CODEX_HOME: codexHomePath } : {}),
},
stdio: ["pipe", "pipe", "pipe"],
shell: process.platform === "win32",
},
stdio: ["pipe", "pipe", "pipe"],
shell: process.platform === "win32",
});
);
const output = readline.createInterface({ input: child.stdout });

context = {
Expand Down
15 changes: 9 additions & 6 deletions apps/server/src/git/Layers/GitCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,12 +539,15 @@ it.layer(TestLayer)("git integration", (it) => {
yield* checkoutGitBranch({ cwd: source, branch: featureBranch });
const core = yield* GitCore;
yield* Effect.promise(() =>
vi.waitFor(async () => {
const details = await Effect.runPromise(core.statusDetails(source));
expect(details.branch).toBe(featureBranch);
expect(details.aheadCount).toBe(0);
expect(details.behindCount).toBe(1);
}),
vi.waitFor(
async () => {
const details = await Effect.runPromise(core.statusDetails(source));
expect(details.branch).toBe(featureBranch);
expect(details.aheadCount).toBe(0);
expect(details.behindCount).toBe(1);
},
{ timeout: 10_000 },
),
);
}),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -862,7 +862,7 @@ describe("CheckpointReactor", () => {
}),
);

const deadline = Date.now() + 2000;
const deadline = Date.now() + 10_000;
const waitForRollbackCalls = async (): Promise<void> => {
if (harness.provider.rollbackConversation.mock.calls.length >= 2) {
return;
Expand Down
4 changes: 2 additions & 2 deletions apps/server/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ export default mergeConfig(
baseConfig,
defineConfig({
test: {
testTimeout: 15_000,
hookTimeout: 15_000,
testTimeout: 240_000,
hookTimeout: 60_000,
},
}),
);
2 changes: 1 addition & 1 deletion apps/web/public/mockServiceWorker.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* - Please do NOT modify this file.
*/

const PACKAGE_VERSION = '2.12.9'
const PACKAGE_VERSION = '2.12.10'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
Expand Down
28 changes: 24 additions & 4 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
hasToolActivityForTurn,
isLatestTurnSettled,
formatElapsed,
formatTurnWorkElapsedExcludingUserInputWait,
} from "../session-logic";
import { isScrollContainerNearBottom } from "../chat-scroll";
import {
Expand Down Expand Up @@ -568,7 +569,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
const phase = derivePhase(activeThread?.session ?? null);
const isSendBusy = sendPhase !== "idle";
const isPreparingWorktree = sendPhase === "preparing-worktree";
const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
const nowIso = new Date(nowTick).toISOString();
const activeWorkStartedAt = deriveActiveWorkStartedAt(
activeLatestTurn,
Expand All @@ -593,6 +593,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
[threadActivities],
);
const activePendingUserInput = pendingUserInputs[0] ?? null;
const isWaitingForUserInput = activePendingUserInput !== null;
const isWorking =
(phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint) &&
!isWaitingForUserInput;
const activePendingDraftAnswers = useMemo(
() =>
activePendingUserInput
Expand Down Expand Up @@ -862,14 +866,27 @@ export default function ChatView({ threadId }: ChatViewProps) {
if (!activeLatestTurn.completedAt) return null;
if (!latestTurnHasToolActivity) return null;

const elapsed = formatElapsed(activeLatestTurn.startedAt, activeLatestTurn.completedAt);
const elapsed = formatTurnWorkElapsedExcludingUserInputWait(
activeLatestTurn.startedAt,
activeLatestTurn.completedAt,
threadActivities,
);
return elapsed ? `Worked for ${elapsed}` : null;
}, [
activeLatestTurn?.completedAt,
activeLatestTurn?.startedAt,
latestTurnHasToolActivity,
latestTurnSettled,
threadActivities,
]);
const workingForLabel = useMemo(() => {
if (!isWorking) return null;
if (!activeWorkStartedAt) return null;
return (
formatTurnWorkElapsedExcludingUserInputWait(activeWorkStartedAt, nowIso, threadActivities) ??
formatElapsed(activeWorkStartedAt, nowIso)
);
}, [activeWorkStartedAt, isWorking, nowIso, threadActivities]);
const completionDividerBeforeEntryId = useMemo(() => {
if (!latestTurnSettled) return null;
if (!activeLatestTurn?.startedAt) return null;
Expand Down Expand Up @@ -1900,14 +1917,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
: "local";

useEffect(() => {
if (phase !== "running") return;
if (phase !== "running" || isWaitingForUserInput) return;
const timer = window.setInterval(() => {
setNowTick(Date.now());
}, 1000);
return () => {
window.clearInterval(timer);
};
}, [phase]);
}, [isWaitingForUserInput, phase]);

const beginSendPhase = useCallback((nextPhase: Exclude<SendPhase, "idle">) => {
setSendStartedAt((current) => current ?? new Date().toISOString());
Expand Down Expand Up @@ -3278,6 +3295,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
key={activeThread.id}
hasMessages={timelineEntries.length > 0}
isWorking={isWorking}
isWaitingForUserInput={isWaitingForUserInput}
waitingStartedAt={activePendingUserInput?.createdAt ?? null}
workingForLabel={workingForLabel}
activeTurnInProgress={isWorking || !latestTurnSettled}
activeTurnStartedAt={activeWorkStartedAt}
scrollContainer={messagesScrollElement}
Expand Down
53 changes: 44 additions & 9 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8;
interface MessagesTimelineProps {
hasMessages: boolean;
isWorking: boolean;
isWaitingForUserInput: boolean;
waitingStartedAt: string | null;
workingForLabel: string | null;
activeTurnInProgress: boolean;
activeTurnStartedAt: string | null;
scrollContainer: HTMLDivElement | null;
Expand All @@ -67,6 +70,9 @@ interface MessagesTimelineProps {
export const MessagesTimeline = memo(function MessagesTimeline({
hasMessages,
isWorking,
isWaitingForUserInput,
waitingStartedAt,
workingForLabel,
activeTurnInProgress,
activeTurnStartedAt,
scrollContainer,
Expand Down Expand Up @@ -113,7 +119,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
return () => {
observer.disconnect();
};
}, [hasMessages, isWorking]);
}, [hasMessages, isWorking, isWaitingForUserInput]);

const rows = useMemo<TimelineRow[]>(() => {
const nextRows: TimelineRow[] = [];
Expand Down Expand Up @@ -175,10 +181,23 @@ export const MessagesTimeline = memo(function MessagesTimeline({
id: "working-indicator-row",
createdAt: activeTurnStartedAt,
});
} else if (isWaitingForUserInput) {
nextRows.push({
kind: "waiting",
id: "waiting-indicator-row",
createdAt: waitingStartedAt ?? activeTurnStartedAt,
});
}

return nextRows;
}, [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt]);
}, [
timelineEntries,
completionDividerBeforeEntryId,
isWorking,
activeTurnStartedAt,
isWaitingForUserInput,
waitingStartedAt,
]);

const firstUnvirtualizedRowIndex = useMemo(() => {
const firstTailRowIndex = Math.max(rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, 0);
Expand All @@ -189,7 +208,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
let firstCurrentTurnRowIndex = -1;
if (!Number.isNaN(turnStartedAtMs)) {
firstCurrentTurnRowIndex = rows.findIndex((row) => {
if (row.kind === "working") return true;
if (row.kind === "working" || row.kind === "waiting") return true;
if (!row.createdAt) return false;
const rowCreatedAtMs = Date.parse(row.createdAt);
return !Number.isNaN(rowCreatedAtMs) && rowCreatedAtMs >= turnStartedAtMs;
Expand Down Expand Up @@ -233,7 +252,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
if (!row) return 96;
if (row.kind === "work") return 112;
if (row.kind === "proposed-plan") return estimateTimelineProposedPlanHeight(row.proposedPlan);
if (row.kind === "working") return 40;
if (row.kind === "working" || row.kind === "waiting") return 40;
return estimateTimelineMessageHeight(row.message, { timelineWidthPx });
},
measureElement: measureVirtualElement,
Expand Down Expand Up @@ -518,17 +537,32 @@ export const MessagesTimeline = memo(function MessagesTimeline({
<span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" />
</span>
<span>
{row.createdAt
? `Working for ${formatWorkingTimer(row.createdAt, nowIso) ?? "0s"}`
: "Working..."}
{workingForLabel
? `Working for ${workingForLabel}`
: row.createdAt
? `Working for ${formatWorkingTimer(row.createdAt, nowIso) ?? "0s"}`
: "Working..."}
</span>
</div>
</div>
)}

{row.kind === "waiting" && (
<div className="py-0.5 pl-1.5">
<div className="flex items-center gap-2 pt-1 text-[11px] text-muted-foreground/70">
<span className="inline-flex items-center gap-[3px]">
<span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" />
<span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" />
<span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" />
</span>
<span>Waiting for you</span>
</div>
</div>
)}
</div>
);

if (!hasMessages && !isWorking) {
if (!hasMessages && !isWorking && !isWaitingForUserInput) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground/30">
Expand Down Expand Up @@ -597,7 +631,8 @@ type TimelineRow =
createdAt: string;
proposedPlan: TimelineProposedPlan;
}
| { kind: "working"; id: string; createdAt: string | null };
| { kind: "working"; id: string; createdAt: string | null }
| { kind: "waiting"; id: string; createdAt: string | null };

function estimateTimelineProposedPlanHeight(proposedPlan: TimelineProposedPlan): number {
const estimatedLines = Math.max(1, Math.ceil(proposedPlan.planMarkdown.length / 72));
Expand Down
Loading