Skip to content
Closed
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: 15 additions & 0 deletions src/components/chat/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,21 @@ export interface Question {
multiSelect?: boolean;
}

export type QueuedTurnKind = 'normal' | 'steer';
export type QueuedTurnStatus = 'queued' | 'paused';

export interface QueuedTurn {
id: string;
sessionId: string;
text: string;
kind: QueuedTurnKind;
status: QueuedTurnStatus;
createdAt: number;
projectName?: string;
projectPath?: string;
sessionMode?: SessionMode;
}

export interface ChatInterfaceProps {
selectedProject: Project | null;
selectedSession: ProjectSession | null;
Expand Down
297 changes: 297 additions & 0 deletions src/components/chat/utils/__tests__/codexQueue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
import { describe, expect, it } from "vitest";

import {
buildQueuedTurn,
enqueueSessionTurn,
getNextDispatchableTurn,
promoteQueuedTurnToSteer,
reconcileSessionQueueId,
reconcileSettledSessionQueue,
type SessionQueueMap,
} from "../codexQueue";

describe("codexQueue", () => {
it("prepends steer turns when enqueueing", () => {
const sessionId = "session-1";
const initialQueue: SessionQueueMap = {
[sessionId]: [
buildQueuedTurn({
id: "normal-1",
sessionId,
text: "normal one",
kind: "normal",
}),
],
};

const next = enqueueSessionTurn(
initialQueue,
buildQueuedTurn({
id: "steer-1",
sessionId,
text: "steer one",
kind: "steer",
}),
);

expect(next[sessionId].map((turn) => turn.id)).toEqual([
"steer-1",
"normal-1",
]);
});

it("chooses a queued steer turn before normal turns", () => {
const queue = [
buildQueuedTurn({
id: "normal-1",
sessionId: "session-1",
text: "normal one",
kind: "normal",
}),
buildQueuedTurn({
id: "steer-1",
sessionId: "session-1",
text: "steer one",
kind: "steer",
}),
];

const next = getNextDispatchableTurn(queue);
expect(next?.id).toBe("steer-1");
});

it("promotes a queued turn to steer and moves it to the top", () => {
const sessionId = "session-1";
const initialQueue: SessionQueueMap = {
[sessionId]: [
buildQueuedTurn({
id: "normal-1",
sessionId,
text: "normal one",
kind: "normal",
}),
buildQueuedTurn({
id: "normal-2",
sessionId,
text: "normal two",
kind: "normal",
}),
],
};

const next = promoteQueuedTurnToSteer(
initialQueue,
sessionId,
"normal-2",
);

expect(next[sessionId][0].id).toBe("normal-2");
expect(next[sessionId][0].kind).toBe("steer");
expect(next[sessionId][1].id).toBe("normal-1");
});

it("reconciles temporary session queues into the settled session while preserving order", () => {
const tempSessionId = "new-session-123";
const settledSessionId = "session-42";
const initialQueue: SessionQueueMap = {
[settledSessionId]: [
buildQueuedTurn({
id: "existing-1",
sessionId: settledSessionId,
text: "existing queued turn",
kind: "normal",
}),
],
[tempSessionId]: [
buildQueuedTurn({
id: "temp-1",
sessionId: tempSessionId,
text: "first temp turn",
kind: "normal",
}),
buildQueuedTurn({
id: "temp-2",
sessionId: tempSessionId,
text: "second temp turn",
kind: "steer",
}),
],
};

const reconciled = reconcileSessionQueueId(
initialQueue,
tempSessionId,
settledSessionId,
);

expect(reconciled[tempSessionId]).toBeUndefined();
expect(reconciled[settledSessionId].map((turn) => turn.id)).toEqual([
"existing-1",
"temp-1",
"temp-2",
]);
expect(reconciled[settledSessionId].map((turn) => turn.sessionId)).toEqual([
settledSessionId,
settledSessionId,
settledSessionId,
]);
});

it("treats reconciliation as a no-op when the source queue is empty", () => {
const initialQueue: SessionQueueMap = {
"session-1": [
buildQueuedTurn({
id: "turn-1",
sessionId: "session-1",
text: "only turn",
kind: "normal",
}),
],
};

const reconciled = reconcileSessionQueueId(
initialQueue,
"new-session-404",
"session-1",
);

expect(reconciled).toBe(initialQueue);
});

it("does not reconcile settled queues for non-temporary fallback ids", () => {
const queueBySession: SessionQueueMap = {
"session-real": [
buildQueuedTurn({
id: "real-1",
sessionId: "session-real",
text: "real turn",
kind: "normal",
}),
],
"session-fallback": [
buildQueuedTurn({
id: "fallback-1",
sessionId: "session-fallback",
text: "fallback turn",
kind: "normal",
}),
],
};

const reconciled = reconcileSettledSessionQueue(
queueBySession,
"session-real",
"session-fallback",
);

expect(reconciled).toBe(queueBySession);
});

it("preserves order under concurrent temp→settled promotions from multiple temp sessions", () => {
const settledId = "session-settled";
const tempA = "new-session-aaa";
const tempB = "new-session-bbb";

const initialQueue: SessionQueueMap = {
[settledId]: [
buildQueuedTurn({
id: "settled-1",
sessionId: settledId,
text: "settled turn",
kind: "normal",
}),
],
[tempA]: [
buildQueuedTurn({
id: "tempA-1",
sessionId: tempA,
text: "temp A first",
kind: "normal",
}),
buildQueuedTurn({
id: "tempA-2",
sessionId: tempA,
text: "temp A second",
kind: "steer",
}),
],
[tempB]: [
buildQueuedTurn({
id: "tempB-1",
sessionId: tempB,
text: "temp B first",
kind: "normal",
}),
],
};

// Simulate two sequential temp→settled promotions (the order they arrive)
const afterA = reconcileSettledSessionQueue(initialQueue, settledId, tempA);
const afterBoth = reconcileSettledSessionQueue(afterA, settledId, tempB);

// tempA and tempB queues should be gone
expect(afterBoth[tempA]).toBeUndefined();
expect(afterBoth[tempB]).toBeUndefined();

// Settled queue should contain all turns in order:
// existing settled → tempA turns → tempB turns
const ids = afterBoth[settledId].map((t) => t.id);
expect(ids).toEqual([
"settled-1",
"tempA-1",
"tempA-2",
"tempB-1",
]);

// All turns should have the settled sessionId
expect(
afterBoth[settledId].every((t) => t.sessionId === settledId),
).toBe(true);

// Steer kind should be preserved through reconciliation
const steerTurn = afterBoth[settledId].find((t) => t.id === "tempA-2");
expect(steerTurn?.kind).toBe("steer");
});

it("preserves order when promotion and reconciliation interleave", () => {
const tempId = "new-session-xyz";
const settledId = "session-final";

let queue: SessionQueueMap = {
[tempId]: [
buildQueuedTurn({
id: "t-1",
sessionId: tempId,
text: "first",
kind: "normal",
}),
buildQueuedTurn({
id: "t-2",
sessionId: tempId,
text: "second",
kind: "normal",
}),
buildQueuedTurn({
id: "t-3",
sessionId: tempId,
text: "third",
kind: "normal",
}),
],
};

// Promote t-3 to steer (moves to front) before reconciliation
queue = promoteQueuedTurnToSteer(queue, tempId, "t-3");
expect(queue[tempId].map((t) => t.id)).toEqual(["t-3", "t-1", "t-2"]);

// Now reconcile temp → settled
queue = reconcileSettledSessionQueue(queue, settledId, tempId);

expect(queue[tempId]).toBeUndefined();
const ids = queue[settledId].map((t) => t.id);
// Promoted steer turn stays at front, then remaining in original order
expect(ids).toEqual(["t-3", "t-1", "t-2"]);
expect(queue[settledId][0].kind).toBe("steer");
expect(queue[settledId].every((t) => t.sessionId === settledId)).toBe(true);
});
});
70 changes: 70 additions & 0 deletions src/components/chat/utils/__tests__/sessionLoadGuards.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';

import {
resolveSessionLoadProvider,
shouldApplySessionLoadResult,
shouldSkipSessionMessageLoad,
} from '../sessionLoadGuards';
import { DEFAULT_PROVIDER } from '../../../../utils/providerPolicy';

describe('session load guards', () => {
it('keeps the selected session provider when valid', () => {
expect(resolveSessionLoadProvider(DEFAULT_PROVIDER)).toBe(DEFAULT_PROVIDER);
});

it('falls back to default provider when provider is missing or invalid', () => {
expect(resolveSessionLoadProvider(undefined)).toBe(DEFAULT_PROVIDER);
expect(resolveSessionLoadProvider(null)).toBe(DEFAULT_PROVIDER);
expect(resolveSessionLoadProvider('unknown-provider')).toBe(DEFAULT_PROVIDER);
});

it('only applies load results for the active, non-cancelled request', () => {
expect(shouldApplySessionLoadResult(1, 1, false)).toBe(true);
expect(shouldApplySessionLoadResult(1, 2, false)).toBe(false);
expect(shouldApplySessionLoadResult(2, 2, true)).toBe(false);
});

it('skips history fetch for temporary session ids', () => {
expect(shouldSkipSessionMessageLoad('new-session-123')).toBe(true);
expect(shouldSkipSessionMessageLoad('temp-abc')).toBe(true);
expect(shouldSkipSessionMessageLoad('019d82e8-1ee3-7860-baa1-24603f424ade')).toBe(false);
expect(shouldSkipSessionMessageLoad('')).toBe(false);
expect(shouldSkipSessionMessageLoad(null)).toBe(false);
});

it('prevents stale request overwrite after a fast session switch', () => {
const requestA = 1;
const requestB = 2;

// Request A started first, then B replaced it.
expect(shouldApplySessionLoadResult(requestA, requestB, false)).toBe(false);
expect(shouldApplySessionLoadResult(requestB, requestB, false)).toBe(true);
});

it('prevents older async loads from overwriting a newer session payload', async () => {
let activeRequestId = 0;
const appliedPayloads: string[] = [];

const runLoad = (payload: string, delayMs: number) => {
activeRequestId += 1;
const requestId = activeRequestId;

return new Promise<void>((resolve) => {
setTimeout(() => {
if (shouldApplySessionLoadResult(requestId, activeRequestId, false)) {
appliedPayloads.push(payload);
}
resolve();
}, delayMs);
});
};

// A starts first but finishes later; B starts later but finishes first.
const loadA = runLoad('session-A', 40);
const loadB = runLoad('session-B', 5);
await Promise.all([loadA, loadB]);

expect(appliedPayloads).toEqual(['session-B']);
});
});

Loading
Loading