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
38 changes: 38 additions & 0 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,8 @@ export type ManagerRuntimeSessionReadback = {
};

export type ChatVisibleMessage = {
queue_input_revision?: number;
queue_input_cancelled?: true;
goal_draft?: ReturnType<typeof normalizeGoalDraft>;
/** Client-side lineage added when messages from several Sessions are merged. */
session_id?: string;
Expand Down Expand Up @@ -965,13 +967,38 @@ export async function fetchChatSession(sessionId: string, signal?: AbortSignal)
return requestJson<ChatSessionSnapshot>(`/api/chat/sessions/${encodeURIComponent(sessionId)}`, { signal });
}

export const chatQueueInputRequestSchema = z.discriminatedUnion("action", [
z.object({action: z.literal("edit"), operation_id: z.string().min(1).max(160),
expected_revision: z.number().int().nonnegative(), message: z.string().min(1).max(24000)}).strict(),
z.object({action: z.literal("cancel"), operation_id: z.string().min(1).max(160),
expected_revision: z.number().int().nonnegative()}).strict(),
]);
export type ChatQueueInputRequest = z.infer<typeof chatQueueInputRequestSchema>;
const chatQueuedInputSchema = z.object({input_revision: z.number().int().nonnegative(), message: z.string(),
attachments: z.array(z.object({id: z.string(), name: z.string(), size: z.number(),
mime_type: z.string(), data_url: z.string()})),
});
export type ChatQueuedInput = z.infer<typeof chatQueuedInputSchema>;
export async function fetchQueuedChatInput(sessionId: string, turnId: string, signal?: AbortSignal) {
// Keep original-source audience projection and complete paging. The visible
// input and its revision come from one canonical user row, never raw model context.
const history = await fetchChatSessionHistory(sessionId, signal, turnId);
const original = history.messages.find(row => row.role === "user" && row.turn_id === turnId);
if (!original) throw new Error("Original queued input is unavailable");
return chatQueuedInputSchema.parse({input_revision: original.queue_input_revision ?? 0,
message: original.reply_context?.source_text ?? original.text, attachments: original.attachments ?? []});
}
const chatQueueSchema = z.object({
ok: z.literal(true), schema_version: z.literal("loopx_chat_store_v1"), session_id: z.string().min(1),
active_turn_id: z.string().min(1).nullable(),
turns: z.array(z.object({
turn_id: z.string().min(1), created_at: z.string(), origin: z.string().nullable(),
preview: z.string(), character_count: z.number().int().nonnegative(),
attachment_count: z.number().int().nonnegative(),
input_revision: z.number().int().nonnegative().default(0),
mutation: z.object({action: z.enum(["edit", "cancel"]), operation_id: z.string().min(1),
expected_revision: z.number().int().nonnegative(), input_revision: z.number().int().positive(),
message: z.string().optional()}).nullable().optional().default(null),
steering: z.object({
schema_version: z.literal("loopx_chat_queue_steering_v1"),
client_ingress_id: z.string().min(1), operation_id: z.string().min(1),
Expand Down Expand Up @@ -1004,6 +1031,17 @@ export async function steerQueuedChatTurn(sessionId: string, queuedId: string, t
}
}

export async function mutateQueuedChatTurn(sessionId: string, queuedId: string, request: ChatQueueInputRequest) {
const value = await requestJson<Record<string, unknown>>(
`/api/chat/sessions/${encodeURIComponent(sessionId)}/queue/${encodeURIComponent(queuedId)}/input`,
{method: "POST", body: JSON.stringify(request), signal: AbortSignal.timeout(15_000)});
if (value.ok !== true || value.session_id !== sessionId || value.queued_turn_id !== queuedId ||
value.operation_id !== request.operation_id || value.action !== request.action ||
value.input_revision !== request.expected_revision + 1 || value.status !== "applied") {
throw new Error("Queue input acknowledgement does not match the original request");
}
}

const chatMessagePageSchema = z.object({
schema_version: z.literal("loopx_chat_message_page_v1"),
offset: z.number().int().nonnegative(), total: z.number().int().nonnegative(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@ import assert from "node:assert/strict";
import { conversationReturnSessions, conversationPendingReturnSessions, reconcileConversationHistory, reconcileConversationReturns } from "./conversation-returns.ts";

const collaboration = { returns: [] };
const queuedInput = [{sourceSessionId: "queued-session", sourceMessageId: "input", sourceTurnId: "queued-turn",
role: "user", text: "Original input"}];
const editedInput = {message_id: "input", turn_id: "queued-turn", role: "user", text: "Corrected input", queue_input_revision: 1};
const corrected = reconcileConversationReturns(queuedInput, "queued-session", [editedInput], () => assert.fail("no duplicate input"));
assert.equal(corrected[0].text, "Corrected input");
assert.equal(reconcileConversationReturns(queuedInput, "queued-session", [{...editedInput, role: "agent"}], () => assert.fail()), queuedInput);
assert.equal(reconcileConversationReturns(queuedInput, "queued-session", [{...editedInput, turn_id: "other-turn"}], () => assert.fail()), queuedInput);
const cancelledInput = {...editedInput, queue_input_revision: 2, queue_input_cancelled: true};
const cancelled = reconcileConversationReturns(corrected, "queued-session", [cancelledInput], () => assert.fail("no duplicate input"));
assert.equal(cancelled[0].queueInputCancelled, true);
assert.equal(reconcileConversationReturns(cancelled, "queued-session", [editedInput], () => assert.fail()), cancelled, "stale reads cannot revert cancellation");
assert.equal(reconcileConversationReturns(queuedInput, "other-session", [cancelledInput], () => assert.fail()), queuedInput);
const liveAnswer = [{...queuedInput[0], role: "assistant", pending: true, text: "Live answer"}];
assert.equal(reconcileConversationReturns(liveAnswer, "queued-session", [{...cancelledInput, role: "agent"}], () => assert.fail())[0].text, "Live answer");
const original = [
{ sourceSessionId: "old", sourceMessageId: "brief", collaboration, text: "Original brief" },
{ sourceSessionId: "current", sourceMessageId: "brief", text: "New conversation" },
Expand Down
15 changes: 12 additions & 3 deletions apps/presentation/dashboard/src/data/conversation-returns.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { ChatVisibleMessage } from "./chat";

type ConversationMessage = {
queueInputRevision?: number;
queueInputCancelled?: true;
sourceSessionId?: string;
sourceMessageId?: string;
sourceTurnId?: string;
Expand Down Expand Up @@ -74,14 +76,21 @@ export function reconcileConversationReturns<T extends ConversationMessage>(
const replyContext = source.reply_context ?? row.replyContext;
// Only the exact canonical user body can switch to its source-backed view.
// A background read never replaces a live answer or a different draft.
const text = row.role === "user" && source.reply_context && row.text === source.text
const inputChanged = row.role === "user" && source.role === "user"
&& (!row.sourceTurnId || source.turn_id === row.sourceTurnId) && typeof source.queue_input_revision === "number"
&& source.queue_input_revision > (row.queueInputRevision ?? 0);
const queueInputRevision = inputChanged ? source.queue_input_revision : row.queueInputRevision;
const queueInputCancelled = inputChanged ? source.queue_input_cancelled : row.queueInputCancelled;
const text = inputChanged ? source.text : row.role === "user" && source.reply_context && row.text === source.text
? source.reply_context.source_text : row.text;
if (row.sourceMessageId === source.message_id && JSON.stringify(row.returnDelivery) === JSON.stringify(returnDelivery)
&& JSON.stringify(row.collaboration) === JSON.stringify(collaboration)
&& JSON.stringify(row.collaborations) === JSON.stringify(collaborations) && row.sourceCreatedAt === sourceCreatedAt
&& JSON.stringify(row.replyContext) === JSON.stringify(replyContext) && row.text === text) return row;
&& JSON.stringify(row.replyContext) === JSON.stringify(replyContext) && row.text === text
&& row.queueInputRevision === queueInputRevision && row.queueInputCancelled === queueInputCancelled) return row;
changed = true;
return { ...row, sourceMessageId: source.message_id, sourceCreatedAt, returnDelivery, collaboration, collaborations, replyContext, text };
return { ...row, sourceMessageId: source.message_id, sourceCreatedAt, returnDelivery, collaboration, collaborations, replyContext, text,
queueInputRevision, queueInputCancelled };
});
for (const message of messages) {
if (message.origin !== "manager_followup" || seen.has(message.message_id)) continue;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ export function ChannelTimeline({
{item.message.role !== "user" ? <span className="personal-message-avatar"><Bot size={17} /></span> : null}
<div>
<header><strong>{item.message.role === "user" ? item.message.agentLabel ?? t("common.you") : item.message.agentLabel ?? t("header.manager")}</strong>{item.message.time ? <time>{item.message.time}</time> : null}</header>
{item.message.queueInputCancelled ? <span>{locale === "zh-CN" ? "已取消排队" : "Cancelled before starting"}</span> : null}
{item.message.attachments?.length ? <div className="personal-message-images">{item.message.attachments.map((attachment) => isTextDocument(attachment.mimeType) ? <DocumentAttachment key={attachment.id} {...attachment} zh={locale === "zh-CN"}/> : <img alt={attachment.name} key={attachment.id} src={attachment.dataUrl} />)}</div> : null}
{item.message.role === "user" ? <p>{item.message.text}</p> : item.message.text ? <HistoricalMessageText text={item.message.text}
initiallyCollapsed={Boolean(historical?.has(messageKey(item.message)))}
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading