Skip to content
Open
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
25 changes: 25 additions & 0 deletions apps/server/scripts/acp-mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1";
const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";
const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;
const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0");
const promptResponseChunkCount = Number(process.env.T3_ACP_PROMPT_RESPONSE_CHUNK_COUNT ?? "0");
const promptResponseChunkText = process.env.T3_ACP_PROMPT_RESPONSE_CHUNK_TEXT;
const promptChunkDelayMs = Number(process.env.T3_ACP_PROMPT_CHUNK_DELAY_MS ?? "0");
const permissionOptionIds = {
allowOnce: process.env.T3_ACP_ALLOW_ONCE_OPTION_ID ?? "allow-once",
allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always",
Expand Down Expand Up @@ -865,6 +868,28 @@ const program = Effect.gen(function* () {
},
});

if (Number.isFinite(promptResponseChunkCount) && promptResponseChunkCount > 0) {
// Emit the response as many small chunks to reproduce heavy streaming
// histories: with the `enableAssistantStreaming` server setting on,
// each chunk persists as its own `thread.message-sent` delta event.
for (let index = 0; index < promptResponseChunkCount; index += 1) {
yield* agent.client.sessionUpdate({
sessionId: requestedSessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: promptResponseChunkText ?? `chunk ${index} of a long streamed response. `,
},
},
});
if (Number.isFinite(promptChunkDelayMs) && promptChunkDelayMs > 0) {
yield* Effect.sleep(`${promptChunkDelayMs} millis`);
}
}
return { stopReason: "end_turn" };
}

yield* agent.client.sessionUpdate({
sessionId: requestedSessionId,
update: {
Expand Down
45 changes: 45 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5899,6 +5899,51 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("subscribeThread sends a fresh snapshot instead of replaying a large gap", () =>
Effect.gen(function* () {
let readEventsCalls = 0;
const thread = makeDefaultOrchestrationReadModel().threads[0]!;

yield* buildAppUnderTest({
layers: {
orchestrationEngine: {
// Head is far ahead of the client's afterSequence (gap > 1000).
latestSequence: Effect.succeed(100_000),
readEvents: () =>
Stream.sync(() => {
readEventsCalls += 1;
return {} as OrchestrationEvent;
}),
},
projectionSnapshotQuery: {
getThreadDetailSnapshot: () =>
Effect.succeed(Option.some({ snapshotSequence: 100_000, thread })),
},
},
});

const wsUrl = yield* getWsServerUrl("/ws");
const items = yield* Effect.scoped(
withWsRpcClient(wsUrl, (client) =>
client[ORCHESTRATION_WS_METHODS.subscribeThread]({
threadId: defaultThreadId,
afterSequence: 5,
requestCompletionMarker: true,
}).pipe(Stream.take(2), Stream.runCollect),
),
);

const [first, second] = Array.from(items);
// Large gap => fresh snapshot, and the unbounded replay is never started.
assert.equal(first?.kind, "snapshot");
if (first?.kind === "snapshot") {
assert.equal(first.snapshot.thread.id, thread.id);
}
assert.deepEqual(second, { kind: "synchronized" });
assert.equal(readEventsCalls, 0);
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("subscribeShell replaces a cursor ahead of the authoritative head", () =>
Effect.gen(function* () {
let readEventsCalls = 0;
Expand Down
76 changes: 46 additions & 30 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,13 @@ const PROVIDER_STATUS_DEBOUNCE_MS = 200;
// Matches the event store's default page size (DEFAULT_READ_FROM_SEQUENCE_LIMIT).
const SHELL_RESUME_MAX_GAP = 1_000;

// Resuming a thread subscription replays every persisted event after the
// client's cursor. Past this gap a single snapshot is cheaper than streaming
// (and having the client fold) thousands of events — long agentic turns
// persist one thread.activity-appended per tool transition, so a thread left
// overnight can easily accumulate several thousand.
const THREAD_RESUME_MAX_GAP = 1_000;

const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([
[ORCHESTRATION_WS_METHODS.dispatchCommand, AuthOrchestrationOperateScope],
[ORCHESTRATION_WS_METHODS.getTurnDiff, AuthOrchestrationReadScope],
Expand Down Expand Up @@ -1401,38 +1408,47 @@ const makeWsRpcLayer = (
// catch-up followed by the buffered/ongoing live events. Overlapping
// events are deduped by sequence on the client.
//
// Read the full range after the cursor (not the store's default
// page-bounded limit): the range is normally tiny (a fresh HTTP
// snapshot sequence) and the per-thread filter runs after reading,
// so a global cap could otherwise omit this thread's events.
// Read the exact range from the cursor through the head captured
// below (not the store's default page-bounded limit): the
// per-thread filter runs after reading, so a global cap could
// otherwise omit this thread's events.
//
// When the cursor is too far behind (or ahead of this engine's
// authoritative state), fall through to a fresh snapshot instead
// of replaying an unbounded backlog — mirroring the shell
// stream's SHELL_RESUME_MAX_GAP fallback.
if (input.afterSequence !== undefined) {
const afterSequence = input.afterSequence;
const catchUpStream = orchestrationEngine
.readEvents(afterSequence, Number.MAX_SAFE_INTEGER)
.pipe(
Stream.filter(isThisThreadDetailEvent),
Stream.map((event) => ({
kind: "event" as const,
event: projectActivityEvent(event),
})),
Stream.mapError(
(cause) =>
new OrchestrationGetSnapshotError({
message: `Failed to replay thread ${input.threadId} events`,
cause,
}),
),
);
const afterCatchUp =
input.requestCompletionMarker === true
? Stream.concat(
Stream.fromEffect(
Queue.offer(liveBuffer, { kind: "synchronized" as const }),
).pipe(Stream.drain),
bufferedLiveStream,
)
: bufferedLiveStream;
return Stream.concat(catchUpStream, afterCatchUp);
const headSequence = yield* orchestrationEngine.latestSequence;
const replayGap = headSequence - afterSequence;
if (replayGap >= 0 && replayGap <= THREAD_RESUME_MAX_GAP) {
const catchUpStream = orchestrationEngine
.readEvents(afterSequence, replayGap)
.pipe(
Stream.filter(isThisThreadDetailEvent),
Stream.map((event) => ({
kind: "event" as const,
event: projectActivityEvent(event),
})),
Stream.mapError(
(cause) =>
new OrchestrationGetSnapshotError({
message: `Failed to replay thread ${input.threadId} events`,
cause,
}),
),
);
const afterCatchUp =
input.requestCompletionMarker === true
? Stream.concat(
Stream.fromEffect(
Queue.offer(liveBuffer, { kind: "synchronized" as const }),
).pipe(Stream.drain),
bufferedLiveStream,
)
: bufferedLiveStream;
return Stream.concat(catchUpStream, afterCatchUp);
}
}

const snapshot = yield* projectionSnapshotQuery
Expand Down
12 changes: 12 additions & 0 deletions apps/web/perf.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Thread Replay Playground</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/perf/main.tsx"></script>
</body>
</html>
139 changes: 32 additions & 107 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,12 @@ import { DraftHeroHeadline } from "./chat/DraftHeroHeadline";
import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
import { MessagesTimeline } from "./chat/MessagesTimeline";
import {
deriveDisplayServerMessages,
deriveRevertTurnCountByUserMessageId,
deriveTimelineMessages,
deriveTurnDiffSummaryByAssistantMessageId,
} from "./chat/threadTimelineModel";
import { ChatHeader } from "./chat/ChatHeader";
import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";
import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
Expand Down Expand Up @@ -2138,21 +2144,10 @@ function ChatViewContent(props: ChatViewProps) {
),
[serverAttachmentIds, serverAttachmentUrls],
);
const displayServerMessages = useMemo<ReadonlyArray<ChatMessage>>(() => {
if (!serverMessages) return [];
return serverMessages.map((message) => {
if (!message.attachments || message.attachments.length === 0) {
return message;
}
return {
...message,
attachments: message.attachments.map((attachment) => {
const previewUrl = serverAttachmentUrlById.get(attachment.id);
return previewUrl ? { ...attachment, previewUrl } : attachment;
}),
};
});
}, [serverAttachmentUrlById, serverMessages]);
const displayServerMessages = useMemo<ReadonlyArray<ChatMessage>>(
() => deriveDisplayServerMessages(serverMessages, serverAttachmentUrlById),
[serverAttachmentUrlById, serverMessages],
);
useEffect(() => {
if (typeof Image === "undefined" || displayServerMessages.length === 0) {
return;
Expand Down Expand Up @@ -2237,58 +2232,15 @@ function ChatViewContent(props: ChatViewProps) {
}
};
}, [attachmentPreviewHandoffByMessageId, clearAttachmentPreviewHandoff, displayServerMessages]);
const timelineMessages = useMemo(() => {
const messages = displayServerMessages;
const serverMessagesWithPreviewHandoff =
Object.keys(attachmentPreviewHandoffByMessageId).length === 0
? messages
: // Spread only fires for the few messages that actually changed;
// unchanged ones early-return their original reference.
// In-place mutation would break React's immutable state contract.
messages.map((message) => {
if (
message.role !== "user" ||
!message.attachments ||
message.attachments.length === 0
) {
return message;
}
const handoffPreviewUrls = attachmentPreviewHandoffByMessageId[message.id];
if (!handoffPreviewUrls || handoffPreviewUrls.length === 0) {
return message;
}

let changed = false;
let imageIndex = 0;
const attachments = message.attachments.map((attachment) => {
if (attachment.type !== "image") {
return attachment;
}
const handoffPreviewUrl = handoffPreviewUrls[imageIndex];
imageIndex += 1;
if (!handoffPreviewUrl || attachment.previewUrl === handoffPreviewUrl) {
return attachment;
}
changed = true;
return {
...attachment,
previewUrl: handoffPreviewUrl,
};
});

return changed ? { ...message, attachments } : message;
});

if (optimisticUserMessages.length === 0) {
return serverMessagesWithPreviewHandoff;
}
const serverIds = new Set(serverMessagesWithPreviewHandoff.map((message) => message.id));
const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id));
if (pendingMessages.length === 0) {
return serverMessagesWithPreviewHandoff;
}
return [...serverMessagesWithPreviewHandoff, ...pendingMessages];
}, [attachmentPreviewHandoffByMessageId, displayServerMessages, optimisticUserMessages]);
const timelineMessages = useMemo(
() =>
deriveTimelineMessages({
displayServerMessages,
attachmentPreviewHandoffByMessageId,
optimisticUserMessages,
}),
[attachmentPreviewHandoffByMessageId, displayServerMessages, optimisticUserMessages],
);
const timelineEntries = useMemo(
() =>
deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries),
Expand All @@ -2306,46 +2258,19 @@ function ChatViewContent(props: ChatViewProps) {
] = useDraftHeroLayoutTransition(isDraftHeroState);
const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
useTurnDiffSummaries(activeThread);
const turnDiffSummaryByAssistantMessageId = useMemo(() => {
const byMessageId = new Map<MessageId, TurnDiffSummary>();
for (const summary of turnDiffSummaries) {
if (!summary.assistantMessageId) continue;
byMessageId.set(summary.assistantMessageId, summary);
}
return byMessageId;
}, [turnDiffSummaries]);
const revertTurnCountByUserMessageId = useMemo(() => {
const byUserMessageId = new Map<MessageId, number>();
for (let index = 0; index < timelineEntries.length; index += 1) {
const entry = timelineEntries[index];
if (!entry || entry.kind !== "message" || entry.message.role !== "user") {
continue;
}

for (let nextIndex = index + 1; nextIndex < timelineEntries.length; nextIndex += 1) {
const nextEntry = timelineEntries[nextIndex];
if (!nextEntry || nextEntry.kind !== "message") {
continue;
}
if (nextEntry.message.role === "user") {
break;
}
const summary = turnDiffSummaryByAssistantMessageId.get(nextEntry.message.id);
if (!summary) {
continue;
}
const turnCount =
summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId];
if (typeof turnCount !== "number") {
break;
}
byUserMessageId.set(entry.message.id, Math.max(0, turnCount - 1));
break;
}
}

return byUserMessageId;
}, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]);
const turnDiffSummaryByAssistantMessageId = useMemo(
() => deriveTurnDiffSummaryByAssistantMessageId(turnDiffSummaries),
[turnDiffSummaries],
);
const revertTurnCountByUserMessageId = useMemo(
() =>
deriveRevertTurnCountByUserMessageId({
timelineEntries,
turnDiffSummaryByAssistantMessageId,
inferredCheckpointTurnCountByTurnId,
}),
[inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId],
);

const gitCwd = activeProject
? projectScriptCwd({
Expand Down
Loading
Loading