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
25 changes: 25 additions & 0 deletions apps/presentation/dashboard/smoke/bound-conversation-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,10 +159,31 @@ try {
assert.equal(recovered.pendingReads?.size, 0);
assert.ok(calls.every(row => row.method === "GET"), "Recovery never starts work");
globalThis.fetch = routedRead;
// If the fresh post-history source read loses its connection, keep the
// verified pages private until a later source check succeeds.
const isBoundDiscovery = (input: RequestInfo | URL) =>
new URL(String(input), origin).searchParams.get("conversation_binding_id") === bindingId;
let sourceChecks = 0;
globalThis.fetch = (input, init) => {
if (isBoundDiscovery(input) && ++sourceChecks === 2) throw new Error("Lost post-history source connection");
return routedRead(input, init);
};
const awaitingSource = await fetchChatHistory(options);
assert.deepEqual(awaitingSource.unavailableSessionIds, [sessionId]);
assert.deepEqual(awaitingSource.snapshots, []);
assert.deepEqual(awaitingSource.messages, []);
globalThis.fetch = routedRead;
calls.length = 0;
const sourceRecovered = await fetchChatHistory(options, awaitingSource);
assert.deepEqual(sourceRecovered.snapshots[0].messages, complete.messages);
assert.deepEqual(calls, [{path: `/api/chat/sessions?conversation_binding_id=${bindingId}`, method: "GET"}],
"A fresh check of the same unchanged binding reuses the complete verified history");

// Current source revocation blocks recovery before another history page.
child.stdin.write("deny\n"); assert.equal((await next()).source_verified, false);
calls.length = 0;
await assert.rejects(() => fetchChatHistory(options, interrupted));
await assert.rejects(() => fetchChatHistory(options, awaitingSource));
assert.ok(calls.every(row => new URL(row.path, origin).pathname === "/api/chat/sessions"));
child.stdin.write("recover\n"); assert.equal((await next()).source_verified, true);
// Permanent denial, a changed source or malformed prefix cannot be retained
Expand Down Expand Up @@ -223,6 +244,10 @@ try {
assert.equal(resumed.messages.at(-1)?.text, "A full new original result. ".repeat(150));
assert.equal(calls.filter(row => new URL(row.path, origin).pathname === "/api/chat/sessions").length, 2,
"The original source is verified again after a suffix read");
calls.length = 0;
const changedWhileWaiting = await fetchChatHistory(options, awaitingSource);
assert.deepEqual(changedWhileWaiting.messages, resumed.messages);
assert.ok(readRequests().length > 2, "A changed transcript cannot publish the pending history");

child.stdin.write("history-rewrite\n");
assert.equal((await next()).history_rows, added.history_rows);
Expand Down
48 changes: 41 additions & 7 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1289,12 +1289,27 @@ export function mergeChatSessionMessages(snapshots: ChatSessionSnapshot[]) {
);
}

function sameHistoryRevision(current: ChatSessionSummary, read: ChatSessionSummary) {
return sameHistorySource(current, read) && Boolean(current.transcript_revision)
&& current.transcript_revision === read.transcript_revision
&& current.updated_at === read.updated_at && current.active_turn_id === read.active_turn_id
&& current.status === read.status;
}

function historyTransportUnavailable(error: unknown) {
return error instanceof ChatApiError && error.payload.error_code === "chat_api_unavailable"
&& (error.payload.http_status === undefined || Number(error.payload.http_status) >= 500);
}

export type ChatHistory = {
messages: ChatVisibleMessage[];
sessions: ChatSessionSummary[];
snapshots: ChatSessionSnapshot[];
unavailableSessionIds: string[];
pendingReads?: Map<string, ChatHistoryReadProgress>;
// Read and verified, but excluded from all displayed history until its
// original binding is checked after the transcript was read.
pendingBoundSnapshot?: {bindingId: string; snapshot: ChatSessionSnapshot};
};

export async function fetchChatHistory(options: {
Expand All @@ -1312,16 +1327,21 @@ export async function fetchChatHistory(options: {
const listed = options.managed ? {sessions: [await resolveManagedChatSession(options.managed, AbortSignal.timeout(CHAT_HISTORY_READ_TIMEOUT_MS))]}
: (!options.bound ? previous : undefined) ?? await fetchChatSessions({ ...options, signal: AbortSignal.timeout(CHAT_HISTORY_READ_TIMEOUT_MS) });
const known = new Map(previous?.snapshots.map((snapshot) => [snapshot.session.session_id, snapshot]));
const pending = previous?.pendingBoundSnapshot;
const current = listed.sessions[0];
// This discovery happened after the retained read. An unchanged exact
// source and revision satisfy the pending check without fetching its pages.
if (!options.managed && pending && pending.bindingId === options.bound?.bindingId && current
&& sameHistoryRevision(current, pending.snapshot.session)) {
known.set(current.session_id, pending.snapshot);
}
const pendingReads = new Map(previous?.pendingReads);
for (const id of pendingReads.keys()) if (!listed.sessions.some(session => session.session_id === id)) pendingReads.delete(id);
// A failed historical read is not an empty transcript. Retrying only the
// missing snapshots keeps this recovery read-only and bounds repeated work.
const missing = listed.sessions.filter((session) => {
const saved = known.get(session.session_id)?.session;
return !saved || (exact && (!sameHistorySource(saved, session) || !session.transcript_revision
|| session.transcript_revision !== saved.transcript_revision
|| session.updated_at !== saved.updated_at || session.active_turn_id !== saved.active_turn_id
|| session.status !== saved.status));
return !saved || (exact && !sameHistoryRevision(session, saved));
});
if (exact) for (const session of missing) known.delete(session.session_id);
const results = await Promise.allSettled(missing.map(async (session) => {
Expand All @@ -1337,8 +1357,7 @@ export async function fetchChatHistory(options: {
} catch (error) {
// Reuse the hook's existing retry schedule only for a lost transport.
// Revoked sources, invalid pages and stale cursors must start a fresh read.
if (!(error instanceof ChatApiError && error.payload.error_code === "chat_api_unavailable"
&& (error.payload.http_status === undefined || Number(error.payload.http_status) >= 500))) pendingReads.delete(session.session_id);
if (!historyTransportUnavailable(error)) pendingReads.delete(session.session_id);
throw error;
}
pendingReads.delete(session.session_id);
Expand All @@ -1353,7 +1372,22 @@ export async function fetchChatHistory(options: {
// A long page sequence must not publish against a connection/Goal removed
// while it was in flight. Reuse the original source owner, never a fallback.
if (missing.length && results.some(result => result.status === "fulfilled")) {
if (options.bound) await resolveBoundChatSession(options.bound, AbortSignal.timeout(CHAT_HISTORY_READ_TIMEOUT_MS));
if (options.bound) {
try {
await resolveBoundChatSession(options.bound, AbortSignal.timeout(CHAT_HISTORY_READ_TIMEOUT_MS));
} catch (error) {
const read = results[0];
if (options.managed || read?.status !== "fulfilled" || !historyTransportUnavailable(error)) throw error;
return {
messages: [],
sessions: listed.sessions,
snapshots: [],
unavailableSessionIds: listed.sessions.map(session => session.session_id),
pendingReads,
pendingBoundSnapshot: {bindingId: options.bound.bindingId, snapshot: read.value},
};
}
}
if (options.managed) await resolveManagedChatSession(options.managed, AbortSignal.timeout(CHAT_HISTORY_READ_TIMEOUT_MS));
}
for (const result of results) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1355,6 +1355,8 @@ export function PersonalWorkspacePage({
const retryingSteering = retainedSteering && retainedSteering.sessionId === conversationSessionId
&& retainedSteering.text.trim() === composer.trim() ? retainedSteering : undefined;
const composerBlocked = Boolean(workspaceConversations?.entryGate) || steering || (!(retryingSteering || queueTurnId) && (sending || conversationTurnRunning));
const historyBlocksSend = Boolean(conversationHistoryState?.sendBlocked
|| (originalConversation && conversationHistoryState?.phase === "unavailable"));
const quickPromptBlocked = Boolean(workspaceConversations?.entryGate) || Boolean(originalConversation && !originalConversation.session?.resumable) || steering || sending || conversationTurnRunning;
// The Goal overview is for reading; its composer folds to one line until asked for.
const [overviewComposerOpen, setOverviewComposerOpen] = useState(false);
Expand Down Expand Up @@ -2406,7 +2408,7 @@ export function PersonalWorkspacePage({
if (modelSettingsSaving[composerDraftKey]) return;
const pendingImages = messageOverride ? [] : imageAttachments;
const message = (messageOverride ?? composer).trim() || (pendingImages.length ? t("composer.imageAnalysisPrompt") : "");
if (!message || composerBlocked || workspaceUnavailable || (originalConversation && !originalConversation.session?.resumable) || (workspaceConversations?.managed && !workspaceConversations.managed.session?.resumable) || conversationHistoryState?.sendBlocked) return;
if (!message || composerBlocked || workspaceUnavailable || (originalConversation && !originalConversation.session?.resumable) || (workspaceConversations?.managed && !workspaceConversations.managed.session?.resumable) || historyBlocksSend) return;
// A retained uncertain request must be durably recovered before any new
// dispatch. Restoring its text alone cannot turn it into a fresh send.
if (conversationSessionId && !readSteeringRequest(composerDraftKey)) {
Expand Down Expand Up @@ -3066,7 +3068,7 @@ export function PersonalWorkspacePage({
{conversationHistoryState && conversationHistoryState.phase !== "ready" ? (
<div className={conversationHistoryState.phase === "loading" ? "sr-only" : "personal-history-notice"} role="status">
<div><span>{t(`history.${conversationHistoryState.phase}`)}</span>
{conversationHistoryState.sendBlocked && conversationHistoryState.phase !== "loading"
{historyBlocksSend && conversationHistoryState.phase !== "loading"
? <span>{t("history.currentSessionRecovering")}</span> : null}</div>
{conversationHistoryState.phase !== "loading" ? <button type="button"
disabled={conversationHistoryState.reading} onClick={conversationHistoryState.retry}>
Expand Down Expand Up @@ -3222,7 +3224,7 @@ export function PersonalWorkspacePage({
setActionFeedback(cause instanceof Error ? cause.message : (locale === "zh-CN" ? "停止失败,请查看当前回合。" : "Stop failed. Check the current turn.")))}>
<Square size={16}/>
</button> : null}
<button className="personal-composer-send" aria-label={t("composer.send")} disabled={modelSettingsSaving[composerDraftKey] || (!composer.trim() && imageAttachments.length === 0) || composerBlocked || workspaceUnavailable || (originalConversation && !originalConversation.session?.resumable) || (workspaceConversations?.managed && !workspaceConversations.managed.session?.resumable) || conversationHistoryState?.sendBlocked} onClick={() => void sendMessage()} title={t("composer.sendMessageHint")} type="button"><Send size={18} /></button>
<button className="personal-composer-send" aria-label={t("composer.send")} disabled={modelSettingsSaving[composerDraftKey] || (!composer.trim() && imageAttachments.length === 0) || composerBlocked || workspaceUnavailable || (originalConversation && !originalConversation.session?.resumable) || (workspaceConversations?.managed && !workspaceConversations.managed.session?.resumable) || historyBlocksSend} onClick={() => void sendMessage()} title={t("composer.sendMessageHint")} type="button"><Send size={18} /></button>
</div>
</div>

Expand Down
Loading