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
2 changes: 2 additions & 0 deletions src/app/root/E2EBootstrap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ export const E2EBootstrap: FC = () => {
inspectOrgtrackFileSessionHistory,
inspectCliSessionStatus,
inspectCliHistoryMutation,
inspectCliHistory,
resetToNewSession,
openSession,
reloadSessionList,
Expand Down Expand Up @@ -436,6 +437,7 @@ export const E2EBootstrap: FC = () => {
inspectOrgtrackFileSessionHistory,
inspectCliSessionStatus,
inspectCliHistoryMutation,
inspectCliHistory,
resetToNewSession,
openSession,
reloadSessionList,
Expand Down
21 changes: 21 additions & 0 deletions src/app/root/e2e/helpers/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,26 @@ export function createSessionHelpers(store: E2EStore) {
}
};

const inspectCliHistory = async (
sessionId: string
): Promise<Result<{ events: Json[] }>> => {
try {
if (!sessionId) {
return {
ok: false,
error: "inspectCliHistory: `sessionId` is required",
};
}
const events = await cliAdapter.loadHistory(
sessionId,
new AbortController().signal
);
return { ok: true, events: events as unknown as Json[] };
} catch (err) {
return asError(err);
}
};

const resetToNewSession = async (): Promise<{ ok: true } | Result<never>> => {
try {
store.set(clearSessionAtom);
Expand Down Expand Up @@ -830,6 +850,7 @@ export function createSessionHelpers(store: E2EStore) {
inspectOrgtrackFileSessionHistory,
inspectCliSessionStatus,
inspectCliHistoryMutation,
inspectCliHistory,
resetToNewSession,
openSession,
reloadSessionList,
Expand Down
1 change: 1 addition & 0 deletions src/app/root/e2e/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,7 @@ export interface E2EHelpers {
inspectCliHistoryMutation: (
sessionId: string
) => Promise<Result<{ mutation: Json | null }>>;
inspectCliHistory: (sessionId: string) => Promise<Result<{ events: Json[] }>>;
resetToNewSession: () => Promise<{ ok: true } | Err>;
openSession: (sessionId: string) => Promise<Result<{ sessionId: string }>>;
debugSessionSecuritySnapshot: (
Expand Down
41 changes: 41 additions & 0 deletions src/engines/SessionCore/services/TurnDispatchService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ vi.mock("./SessionService", () => ({
}));

const SESSION = "sdeagent-session-1";
const CLI_SESSION = "cliagent-session-1";

describe("TurnDispatchService", () => {
beforeEach(() => {
Expand All @@ -70,6 +71,7 @@ describe("TurnDispatchService", () => {
afterEach(() => {
resetTurnDispatchMonitorsForTests();
clearRecentOptimisticTurn(SESSION);
clearRecentOptimisticTurn(CLI_SESSION);
clearRecentOptimisticTurn("cursoride-session-1");
resetTurnLifecycleForTests();
});
Expand Down Expand Up @@ -469,6 +471,45 @@ describe("TurnDispatchService", () => {
}
});

it("polls accepted CLI finality when no live status channel is available", async () => {
vi.useFakeTimers();
try {
mocks.sendMessage.mockResolvedValueOnce({
duplicate: false,
turnIntentStatus: "running",
effectiveTurnIntentId: "intent-cli-background",
});
mocks.getTurnIntentStatus.mockResolvedValueOnce({
status: "completed",
effectiveTurnIntentId: "intent-cli-background",
});
const dispatch = reserveTurnDispatch({
sessionId: CLI_SESSION,
turnIntentId: "intent-cli-background",
});

await sendReservedTurn({
dispatch,
content: "background CLI turn",
turnIntentSource: "user_submit",
});
const outcome = waitForTurnOutcome(dispatch, Date.now() + 1_000);
expect(vi.getTimerCount()).toBe(2);

await vi.advanceTimersByTimeAsync(100);

await expect(outcome).resolves.toMatchObject({ status: "completed" });
expect(mocks.getTurnIntentStatus).toHaveBeenCalledWith(
CLI_SESSION,
"intent-cli-background"
);
expect(vi.getTimerCount()).toBe(0);
} finally {
resetTurnDispatchMonitorsForTests();
vi.useRealTimers();
}
});

it("stops an ambiguous exact-X monitor on a live terminal", async () => {
vi.useFakeTimers();
try {
Expand Down
12 changes: 11 additions & 1 deletion src/engines/SessionCore/services/TurnDispatchService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ import {
setSessionRuntimeStatusAtom,
} from "@src/store/session/cliSessionStatusAtom";
import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore";
import { isCursorIdeSession } from "@src/util/session/sessionDispatch";
import {
isCliSession,
isCursorIdeSession,
} from "@src/util/session/sessionDispatch";

import { SessionService } from "./SessionService";
import type { SessionSendMessageParams } from "./types";
Expand Down Expand Up @@ -541,6 +544,13 @@ export async function sendReservedTurn(

confirmTurnRunning(dispatch.sessionId, { generation: dispatch.generation });
markSessionActive(dispatch.sessionId);
if (isCliSession(dispatch.sessionId)) {
// CLI live status normally arrives over the window-level WebSocket, but
// hidden/background runners and isolated app instances may not have that
// channel. Reuse the canonical exact-intent durable monitor so provider
// finality never depends on a mounted transcript or an active socket.
startEffectiveTurnStatusMonitor(dispatch, effectiveTurnIntentId);
}
if (isCursorIdeSession(dispatch.sessionId)) {
if (getTurnGeneration(dispatch.sessionId) !== dispatch.generation) {
return { ...dispatch, accepted: true };
Expand Down
56 changes: 56 additions & 0 deletions src/engines/SessionCore/sync/authoritativeSessionEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Canonical full-history read for a managed local session.
*
* Most runtimes persist normalized events in EventStore. External CLI
* sessions can instead keep their transcript exclusively in the provider's
* native store, so an empty EventStore is not proof of an empty transcript.
* Keep that distinction here so cloud sync, background continuation, and
* future consumers cannot accidentally publish a hollow CLI session.
*/
import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy";
import type { SessionEvent } from "@src/engines/SessionCore/core/types";
import { isCliSession } from "@src/util/session/sessionDispatch";

import { loadCliHistory } from "./adapters/cli/cliHistory";

export interface AuthoritativeSessionEvents {
events: SessionEvent[];
/** Stable EventStore revision when EventStore was the authoritative source. */
localContentRevision?: number;
source: "event_store" | "cli_history";
}

export async function loadAuthoritativeSessionEvents(
sessionId: string,
signal: AbortSignal = new AbortController().signal
): Promise<AuthoritativeSessionEvents> {
// CLI adapters own both of their durable transcript modes: legacy chunks
// and provider-native stores. EventStore can contain only an optimistic
// user row while the native transcript already contains the completed
// assistant tail, so "persisted is non-empty" is not an authority test.
if (isCliSession(sessionId)) {
return {
events: await loadCliHistory(sessionId, signal),
source: "cli_history",
};
}

const revisionBefore =
await eventStoreProxy.getPersistedEventRevision(sessionId);
const persisted = await eventStoreProxy.getPersistedEvents(sessionId);
const revisionAfter =
await eventStoreProxy.getPersistedEventRevision(sessionId);
const localContentRevision =
revisionBefore &&
revisionAfter &&
revisionBefore.revision === revisionAfter.revision &&
revisionAfter.eventCount === persisted.length
? revisionAfter.revision
: undefined;

return {
events: persisted,
localContentRevision,
source: "event_store",
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";

import type { SessionEvent } from "@src/engines/SessionCore/core/types";

import { sliceAppendedTurnTail } from "./conversationTurnEvents";

function event(
id: string,
source: "user" | "assistant" | "system"
): SessionEvent {
return {
id,
chunk_id: id,
sessionId: "cliagent-session",
source,
} as SessionEvent;
}

describe("sliceAppendedTurnTail", () => {
it("uses a stable native-transcript prefix as the turn boundary", () => {
const before = [event("user-old", "user"), event("reply-old", "assistant")];
const after = [
...before,
event("user-new", "user"),
event("tool-new", "system"),
event("reply-new", "assistant"),
];

expect(
sliceAppendedTurnTail(before, after)?.map((item) => item.id)
).toEqual(["tool-new", "reply-new"]);
});

it("fails closed when the provider rewrites the previous prefix", () => {
const before = [event("user-old", "user"), event("reply-old", "assistant")];
const after = [
event("user-old", "user"),
event("reply-rewritten", "assistant"),
event("user-new", "user"),
event("reply-new", "assistant"),
];

expect(sliceAppendedTurnTail(before, after)).toBeNull();
});

it("requires an appended user boundary", () => {
expect(
sliceAppendedTurnTail([], [event("assistant-only", "assistant")])
).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,43 @@ export function sliceTurnTailByIntent(
}
return tail;
}

/**
* Slice one newly appended native-transcript turn from two authoritative
* snapshots. External CLIs own their transcript schema and therefore cannot
* persist ORG2's internal turnIntentId. Their stable normalized event ids are
* the boundary instead: the old snapshot must remain an exact prefix, then
* the first appended user row anchors the new agent tail.
*
* `null` fails closed when history was rewritten or no user boundary exists;
* publishing from an ambiguous offset could duplicate an older agent turn.
*/
export function sliceAppendedTurnTail(
before: readonly SessionEvent[],
after: readonly SessionEvent[]
): SessionEvent[] | null {
if (after.length < before.length) return null;
for (let index = 0; index < before.length; index += 1) {
const previous = before[index];
const current = after[index];
if (
previous.id !== current.id ||
previous.chunk_id !== current.chunk_id ||
previous.source !== current.source
) {
return null;
}
}

const appended = after.slice(before.length);
const userIndex = appended.findIndex((event) => event.source === "user");
if (userIndex < 0) return null;

const tail: SessionEvent[] = [];
for (let index = userIndex + 1; index < appended.length; index += 1) {
const event = appended[index];
if (event.source === "user") break;
tail.push(event);
}
return tail;
}
Loading
Loading