mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
feat(webchat): reply-to a message with hydrated reply context (#110791)
* feat(webchat): reply-to a message with hydrated reply context Control UI replies now carry the target transcript id as replyToId on chat.send. The Gateway resolves the replied-to message from session history and hydrates the channel-agnostic ReplyToId/ReplyToBody/ ReplyToSender envelope fields, so agents receive reply_to_id, has_reply_context, and the untrusted reply-target block exactly like Discord replies (mirrors #90263). Reply targets without a persisted transcript id keep the inline-quote fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(webchat): keep non-reply chat.send dispatch ordering and satisfy CI gates * docs(webchat): match reply-context doc to webchat conversation-info policy * fix(webchat): hydrate reply bodies from display-visible content only --------- Co-authored-by: openclaw-clawsweeper[bot] <openclaw-clawsweeper[bot]@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
This commit is contained in:
co-authored by
openclaw-clawsweeper[bot] <openclaw-clawsweeper[bot]@users.noreply.github.com>
Claude Fable 5
fuller-stack-dev
parent
4d683904df
commit
b88646cf78
@@ -14043,6 +14043,7 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
public let originatingto: String?
|
||||
public let originatingaccountid: String?
|
||||
public let originatingthreadid: String?
|
||||
public let replytoid: String?
|
||||
public let attachments: [AnyCodable]?
|
||||
public let toolbindings: [String: AnyCodable]?
|
||||
public let timeoutms: Int?
|
||||
@@ -14066,6 +14067,7 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
originatingto: String? = nil,
|
||||
originatingaccountid: String? = nil,
|
||||
originatingthreadid: String? = nil,
|
||||
replytoid: String? = nil,
|
||||
attachments: [AnyCodable]? = nil,
|
||||
toolbindings: [String: AnyCodable]? = nil,
|
||||
timeoutms: Int? = nil,
|
||||
@@ -14088,6 +14090,7 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
self.originatingto = originatingto
|
||||
self.originatingaccountid = originatingaccountid
|
||||
self.originatingthreadid = originatingthreadid
|
||||
self.replytoid = replytoid
|
||||
self.attachments = attachments
|
||||
self.toolbindings = toolbindings
|
||||
self.timeoutms = timeoutms
|
||||
@@ -14111,6 +14114,7 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
originatingto: String? = nil,
|
||||
originatingaccountid: String? = nil,
|
||||
originatingthreadid: String? = nil,
|
||||
replytoid: String? = nil,
|
||||
attachments: [AnyCodable]? = nil,
|
||||
toolbindings: [String: AnyCodable]? = nil,
|
||||
timeoutms: Int? = nil,
|
||||
@@ -14134,6 +14138,7 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
originatingto: originatingto,
|
||||
originatingaccountid: originatingaccountid,
|
||||
originatingthreadid: originatingthreadid,
|
||||
replytoid: replytoid,
|
||||
attachments: attachments,
|
||||
toolbindings: toolbindings,
|
||||
timeoutms: timeoutms,
|
||||
@@ -14158,6 +14163,7 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
case originatingto = "originatingTo"
|
||||
case originatingaccountid = "originatingAccountId"
|
||||
case originatingthreadid = "originatingThreadId"
|
||||
case replytoid = "replyToId"
|
||||
case attachments
|
||||
case toolbindings = "toolBindings"
|
||||
case timeoutms = "timeoutMs"
|
||||
|
||||
@@ -29,6 +29,7 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket. N
|
||||
- Compaction entries render as a "Compacted history" divider explaining that the compacted transcript is preserved as a checkpoint, with an action to open session checkpoints (branch or restore, when permissions allow).
|
||||
- Control UI remembers the backing Gateway `sessionId` returned by `chat.history` and includes it on follow-up `chat.send` calls, so reconnects and page refreshes continue the same stored conversation unless the user starts or resets a session.
|
||||
- `chat.send` takes an idempotency key (Control UI uses the run id); the Gateway dedupes repeated requests that reuse the same key, so retried or duplicate in-flight submits for the same session/message/attachments do not create a second run.
|
||||
- Replying to a specific message (right-click → Reply) sends the target's transcript id as `replyToId` on `chat.send`. The Gateway resolves that message from session history and hydrates the same channel-agnostic reply context metadata Discord replies use: agents see `has_reply_context` plus the untrusted "Reply target of current user message" block with sender label and body. (Webchat prompts keep volatile conversation ids such as `reply_to_id` suppressed, per the existing byte-stable prompt policy for direct webchat sessions.) Reply targets without a persisted transcript id (for example pending sends) fall back to an inline quote in the message body.
|
||||
- Workspace startup files and pending `BOOTSTRAP.md` instructions are supplied through the agent system prompt's `# Project Context` section, not copied into the WebChat user message. If bootstrap content is truncated, the system prompt gets a short "Bootstrap Context Notice" instead; detailed counts and config knobs stay on diagnostic surfaces.
|
||||
- Display normalization on `chat.history` strips: runtime-only OpenClaw context, inbound envelope wrappers, inline delivery directive tags such as `[[reply_to_current]]`, `[[reply_to:<id>]]`, and `[[audio_as_voice]]`, plain-text tool-call XML payloads (`<tool_call>`, `<function_call>`, `<tool_calls>`, `<function_calls>`, including truncated blocks), and leaked ASCII/full-width model control tokens. Assistant entries whose whole visible text is only the silent token `NO_REPLY` (case-insensitive) are omitted.
|
||||
- Reasoning-flagged reply payloads (`isReasoning: true`) are excluded from WebChat assistant content, transcript replay text, and audio content blocks, so thinking-only payloads do not surface as visible assistant messages or playable audio.
|
||||
|
||||
@@ -110,6 +110,9 @@ export const ChatSendParamsSchema = closedObject({
|
||||
originatingTo: Type.Optional(Type.String()),
|
||||
originatingAccountId: Type.Optional(Type.String()),
|
||||
originatingThreadId: Type.Optional(Type.String()),
|
||||
// Transcript id of the message this send replies to; the Gateway hydrates
|
||||
// channel-agnostic reply context metadata from session history.
|
||||
replyToId: Type.Optional(NonEmptyString),
|
||||
attachments: Type.Optional(ChatAttachmentsSchema),
|
||||
toolBindings: Type.Optional(RunToolBindingsSchema),
|
||||
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// Covers webchat reply-target hydration into the channel-agnostic ReplyTo*
|
||||
// envelope fields consumed by inbound-meta reply context blocks.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MsgContext } from "../../auto-reply/templating.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import {
|
||||
applyChatSendReplyContextFields,
|
||||
resolveChatSendReplyContext,
|
||||
} from "./chat-send-reply-context.js";
|
||||
|
||||
const readSessionMessageByIdAsyncMock = vi.fn();
|
||||
const resolveAssistantIdentityMock = vi.fn((..._args: unknown[]) => ({
|
||||
agentId: "main",
|
||||
name: "Molty",
|
||||
avatar: "M",
|
||||
}));
|
||||
|
||||
vi.mock("../session-transcript-readers.js", () => ({
|
||||
readSessionMessageByIdAsync: (...args: unknown[]) => readSessionMessageByIdAsyncMock(...args),
|
||||
}));
|
||||
vi.mock("../assistant-identity.js", () => ({
|
||||
resolveAssistantIdentity: (...args: unknown[]) => resolveAssistantIdentityMock(...args),
|
||||
}));
|
||||
|
||||
const cfg = {} as OpenClawConfig;
|
||||
|
||||
function baseParams(overrides: Partial<Parameters<typeof resolveChatSendReplyContext>[0]> = {}) {
|
||||
return {
|
||||
replyToId: "msg-1",
|
||||
cfg,
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:webchat",
|
||||
sessionEntry: { sessionFile: "session.jsonl", sessionId: "session-1" },
|
||||
storePath: "/tmp/sessions.json",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveChatSendReplyContext", () => {
|
||||
beforeEach(() => {
|
||||
readSessionMessageByIdAsyncMock.mockReset();
|
||||
});
|
||||
|
||||
it("returns no fields without a reply id", async () => {
|
||||
expect(await resolveChatSendReplyContext(baseParams({ replyToId: undefined }))).toEqual({});
|
||||
expect(await resolveChatSendReplyContext(baseParams({ replyToId: " " }))).toEqual({});
|
||||
expect(readSessionMessageByIdAsyncMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hydrates assistant reply targets with the assistant identity label", async () => {
|
||||
readSessionMessageByIdAsyncMock.mockResolvedValue({
|
||||
found: true,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "the replied-to answer" }],
|
||||
__openclaw: { id: "msg-1" },
|
||||
},
|
||||
});
|
||||
|
||||
const fields = await resolveChatSendReplyContext(baseParams());
|
||||
|
||||
expect(readSessionMessageByIdAsyncMock).toHaveBeenCalledWith(
|
||||
{
|
||||
agentId: "main",
|
||||
sessionEntry: { sessionFile: "session.jsonl", sessionId: "session-1" },
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:webchat",
|
||||
storePath: "/tmp/sessions.json",
|
||||
},
|
||||
"msg-1",
|
||||
{ allowResetArchiveFallback: true },
|
||||
);
|
||||
expect(fields).toEqual({
|
||||
ReplyToId: "msg-1",
|
||||
ReplyToBody: "the replied-to answer",
|
||||
ReplyToSender: "Molty",
|
||||
});
|
||||
});
|
||||
|
||||
it("labels user reply targets with the client display name", async () => {
|
||||
readSessionMessageByIdAsyncMock.mockResolvedValue({
|
||||
found: true,
|
||||
message: { role: "user", content: "an earlier question" },
|
||||
});
|
||||
|
||||
const fields = await resolveChatSendReplyContext(baseParams({ userSenderLabel: "Ada" }));
|
||||
|
||||
expect(fields).toEqual({
|
||||
ReplyToId: "msg-1",
|
||||
ReplyToBody: "an earlier question",
|
||||
ReplyToSender: "Ada",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps only the reply id when the target message is missing", async () => {
|
||||
readSessionMessageByIdAsyncMock.mockResolvedValue({ found: false });
|
||||
|
||||
expect(await resolveChatSendReplyContext(baseParams())).toEqual({ ReplyToId: "msg-1" });
|
||||
});
|
||||
|
||||
it("keeps only the reply id when no session exists yet", async () => {
|
||||
expect(await resolveChatSendReplyContext(baseParams({ sessionEntry: undefined }))).toEqual({
|
||||
ReplyToId: "msg-1",
|
||||
});
|
||||
expect(readSessionMessageByIdAsyncMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tolerates read failures and reports them through warn", async () => {
|
||||
readSessionMessageByIdAsyncMock.mockRejectedValue(new Error("transcript unavailable"));
|
||||
const warn = vi.fn();
|
||||
|
||||
expect(await resolveChatSendReplyContext(baseParams({ warn }))).toEqual({
|
||||
ReplyToId: "msg-1",
|
||||
});
|
||||
expect(warn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("hydrates only display-visible content, not raw transcript payloads", async () => {
|
||||
readSessionMessageByIdAsyncMock.mockResolvedValue({
|
||||
found: true,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "visible answer" },
|
||||
{ type: "text", text: '<tool_call>{"name":"exec","arguments":{}}</tool_call>' },
|
||||
],
|
||||
__openclaw: { id: "msg-1" },
|
||||
},
|
||||
});
|
||||
|
||||
const fields = await resolveChatSendReplyContext(baseParams());
|
||||
|
||||
expect(fields.ReplyToBody).toContain("visible answer");
|
||||
expect(fields.ReplyToBody).not.toContain("tool_call");
|
||||
});
|
||||
|
||||
it("strips inbound envelope wrappers from user reply targets", async () => {
|
||||
readSessionMessageByIdAsyncMock.mockResolvedValue({
|
||||
found: true,
|
||||
message: {
|
||||
role: "user",
|
||||
content: "[Sat 2026-07-18 11:31 MDT] Which stage runs the integration tests?",
|
||||
},
|
||||
});
|
||||
|
||||
const fields = await resolveChatSendReplyContext(baseParams({ userSenderLabel: "Ada" }));
|
||||
|
||||
expect(fields.ReplyToBody).toBe("Which stage runs the integration tests?");
|
||||
});
|
||||
|
||||
it("keeps only the reply id when the target is not display-visible", async () => {
|
||||
readSessionMessageByIdAsyncMock.mockResolvedValue({
|
||||
found: true,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "NO_REPLY" }],
|
||||
__openclaw: { id: "msg-1" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(await resolveChatSendReplyContext(baseParams())).toEqual({ ReplyToId: "msg-1" });
|
||||
});
|
||||
|
||||
it("bounds oversized reply bodies", async () => {
|
||||
readSessionMessageByIdAsyncMock.mockResolvedValue({
|
||||
found: true,
|
||||
message: { role: "user", content: "x".repeat(5000) },
|
||||
});
|
||||
|
||||
const fields = await resolveChatSendReplyContext(baseParams());
|
||||
|
||||
expect(fields.ReplyToBody?.length).toBeLessThanOrEqual(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyChatSendReplyContextFields", () => {
|
||||
it("assigns only hydrated fields", () => {
|
||||
const ctx = { Body: "hi" } as MsgContext;
|
||||
applyChatSendReplyContextFields(ctx, { ReplyToId: "msg-1", ReplyToBody: "quoted" });
|
||||
|
||||
expect(ctx.ReplyToId).toBe("msg-1");
|
||||
expect(ctx.ReplyToBody).toBe("quoted");
|
||||
expect("ReplyToSender" in ctx).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
// Hydrates Control UI (webchat) reply targets into the channel-agnostic
|
||||
// ReplyTo* envelope fields so downstream reply-context handling matches the
|
||||
// Discord path (reply_to_id + "Reply target of current user message" block).
|
||||
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import type { MsgContext } from "../../auto-reply/templating.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { sanitizeAssistantVisibleTextWithProfile } from "../../shared/text/assistant-visible-text.js";
|
||||
import { resolveAssistantIdentity } from "../assistant-identity.js";
|
||||
import { projectChatDisplayMessage } from "../chat-display-projection.js";
|
||||
import {
|
||||
readSessionMessageByIdAsync,
|
||||
type SessionTranscriptReadScope,
|
||||
} from "../session-transcript-readers.js";
|
||||
|
||||
// Reply targets are quoted context, not primary input: bound the body so a
|
||||
// reply to a huge transcript entry cannot flood the prompt metadata.
|
||||
const REPLY_CONTEXT_BODY_MAX_CHARS = 2000;
|
||||
|
||||
type ChatSendReplyContextFields = Partial<
|
||||
Pick<MsgContext, "ReplyToId" | "ReplyToBody" | "ReplyToSender">
|
||||
>;
|
||||
|
||||
function extractReplyTargetText(message: unknown): string | undefined {
|
||||
const entry = asOptionalRecord(message);
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof entry.text === "string" && entry.text.trim()) {
|
||||
return entry.text;
|
||||
}
|
||||
if (typeof entry.content === "string" && entry.content.trim()) {
|
||||
return entry.content;
|
||||
}
|
||||
if (!Array.isArray(entry.content)) {
|
||||
return undefined;
|
||||
}
|
||||
const parts = entry.content
|
||||
.map((block) => {
|
||||
const record = asOptionalRecord(block);
|
||||
return record && typeof record.text === "string" ? record.text : "";
|
||||
})
|
||||
.filter((text) => text.trim());
|
||||
return parts.length > 0 ? parts.join("\n") : undefined;
|
||||
}
|
||||
|
||||
function resolveReplyTargetSenderLabel(params: {
|
||||
message: unknown;
|
||||
cfg: OpenClawConfig;
|
||||
agentId?: string;
|
||||
userSenderLabel?: string;
|
||||
}): string {
|
||||
const role = asOptionalRecord(params.message)?.role;
|
||||
if (role === "assistant") {
|
||||
return resolveAssistantIdentity({ cfg: params.cfg, agentId: params.agentId }).name;
|
||||
}
|
||||
const userLabel = params.userSenderLabel?.trim();
|
||||
return userLabel || "User";
|
||||
}
|
||||
|
||||
/** Copies hydrated reply fields onto the inbound context without clobbering unset keys. */
|
||||
export function applyChatSendReplyContextFields(
|
||||
ctx: MsgContext,
|
||||
fields: ChatSendReplyContextFields,
|
||||
): void {
|
||||
if (fields.ReplyToId !== undefined) {
|
||||
ctx.ReplyToId = fields.ReplyToId;
|
||||
}
|
||||
if (fields.ReplyToBody !== undefined) {
|
||||
ctx.ReplyToBody = fields.ReplyToBody;
|
||||
}
|
||||
if (fields.ReplyToSender !== undefined) {
|
||||
ctx.ReplyToSender = fields.ReplyToSender;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a webchat reply target from session history. Always preserves the
|
||||
* reply_to_id linkage; body/sender hydrate only when the transcript message
|
||||
* still resolves, mirroring Discord's missing-referenced-message tolerance.
|
||||
*/
|
||||
export async function resolveChatSendReplyContext(params: {
|
||||
replyToId: string | undefined;
|
||||
cfg: OpenClawConfig;
|
||||
agentId?: string;
|
||||
sessionKey: string;
|
||||
sessionEntry?: SessionTranscriptReadScope["sessionEntry"];
|
||||
storePath: string | undefined;
|
||||
userSenderLabel?: string;
|
||||
warn?: (message: string) => void;
|
||||
}): Promise<ChatSendReplyContextFields> {
|
||||
const replyToId = params.replyToId?.trim();
|
||||
if (!replyToId) {
|
||||
return {};
|
||||
}
|
||||
const fields: ChatSendReplyContextFields = { ReplyToId: replyToId };
|
||||
const sessionId = params.sessionEntry?.sessionId;
|
||||
if (!sessionId) {
|
||||
return fields;
|
||||
}
|
||||
try {
|
||||
const resolved = await readSessionMessageByIdAsync(
|
||||
{
|
||||
agentId: params.agentId,
|
||||
sessionEntry: params.sessionEntry,
|
||||
sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
storePath: params.storePath,
|
||||
},
|
||||
replyToId,
|
||||
{ allowResetArchiveFallback: true },
|
||||
);
|
||||
if (!resolved.found) {
|
||||
return fields;
|
||||
}
|
||||
// Hydrate only what webchat displays: project the stored message through the
|
||||
// same chat.history display normalization so envelope wrappers, runtime
|
||||
// context, tool payloads, and reasoning-only content stay out of the prompt.
|
||||
const displayMessage = projectChatDisplayMessage(resolved.message);
|
||||
if (!displayMessage) {
|
||||
return fields;
|
||||
}
|
||||
const rawBody = extractReplyTargetText(displayMessage)?.trim();
|
||||
// Assistant targets additionally scrub tool-call markers, thinking tags,
|
||||
// and internal scaffolding, matching stored-message history text handling.
|
||||
const body =
|
||||
rawBody && displayMessage.role === "assistant"
|
||||
? sanitizeAssistantVisibleTextWithProfile(rawBody, "history").trim()
|
||||
: rawBody;
|
||||
if (!body) {
|
||||
return fields;
|
||||
}
|
||||
fields.ReplyToBody = truncateUtf16Safe(body, REPLY_CONTEXT_BODY_MAX_CHARS);
|
||||
fields.ReplyToSender = resolveReplyTargetSenderLabel({
|
||||
message: displayMessage,
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
userSenderLabel: params.userSenderLabel,
|
||||
});
|
||||
return fields;
|
||||
} catch (err) {
|
||||
params.warn?.(`chat.send reply context hydration failed for ${replyToId}: ${String(err)}`);
|
||||
return fields;
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ type ChatSendRequestParams = {
|
||||
originatingTo?: string;
|
||||
originatingAccountId?: string;
|
||||
originatingThreadId?: string;
|
||||
replyToId?: string;
|
||||
attachments?: Array<{
|
||||
type?: string;
|
||||
mimeType?: string;
|
||||
|
||||
@@ -123,6 +123,10 @@ import {
|
||||
respondChatSessionRoutingChanged,
|
||||
runChatSendPreAdmission,
|
||||
} from "./chat-send-pre-admission.js";
|
||||
import {
|
||||
applyChatSendReplyContextFields,
|
||||
resolveChatSendReplyContext,
|
||||
} from "./chat-send-reply-context.js";
|
||||
import { createChatSendReplyDispatch } from "./chat-send-reply-dispatch.js";
|
||||
import { normalizeChatSendRequest } from "./chat-send-request.js";
|
||||
import { prepareChatSendSession } from "./chat-send-session.js";
|
||||
@@ -1199,6 +1203,22 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
logGateway: context.logGateway,
|
||||
userTurn,
|
||||
});
|
||||
// Resolve the reply target from session history in parallel with the
|
||||
// remaining dispatch prep so replies do not delay the first model call.
|
||||
// Skipped entirely for non-reply sends so their dispatch path keeps its
|
||||
// existing await ordering.
|
||||
const replyContextFieldsPromise = p.replyToId
|
||||
? resolveChatSendReplyContext({
|
||||
replyToId: p.replyToId,
|
||||
cfg,
|
||||
agentId,
|
||||
sessionKey,
|
||||
sessionEntry: entry,
|
||||
storePath,
|
||||
userSenderLabel: clientInfo?.displayName,
|
||||
warn: (message) => context.logGateway.warn(message),
|
||||
})
|
||||
: undefined;
|
||||
|
||||
let agentRunStarted = false;
|
||||
const { deliveredReplies, dispatcher, hasAppendedWebchatAgentMedia, onModelSelected } =
|
||||
@@ -1262,6 +1282,9 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
"gateway.chat_send.dispatch_inbound",
|
||||
async () => {
|
||||
applyChatSendManagedMediaFields(ctx, await pluginBoundMediaFieldsPromise);
|
||||
if (replyContextFieldsPromise) {
|
||||
applyChatSendReplyContextFields(ctx, await replyContextFieldsPromise);
|
||||
}
|
||||
const dispatchResult = await dispatchInboundMessage({
|
||||
ctx,
|
||||
cfg,
|
||||
|
||||
@@ -20,6 +20,8 @@ export type ChatQueueItem = {
|
||||
kind?: "queued" | "steered";
|
||||
attachments?: ChatAttachment[];
|
||||
refreshSessions?: boolean;
|
||||
/** Transcript id of the replied-to message; Gateway hydrates reply context. */
|
||||
replyToId?: string;
|
||||
localCommandArgs?: string;
|
||||
localCommandName?: string;
|
||||
pendingRunId?: string;
|
||||
|
||||
@@ -4935,6 +4935,39 @@ describe("handleSendChat", () => {
|
||||
expect(host.chatReplyTarget).toBeNull();
|
||||
});
|
||||
|
||||
it("sends replyToId instead of an inline quote when the reply target has a transcript id", async () => {
|
||||
const sent = createDeferred<unknown>();
|
||||
const request = vi.fn((method: string, _params?: unknown) => {
|
||||
if (method === "chat.send") {
|
||||
return sent.promise;
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
const host = makeHost({
|
||||
client: { request } as unknown as ChatHost["client"],
|
||||
chatMessage: "continue",
|
||||
chatReplyTarget: {
|
||||
messageId: "id:transcript-abc",
|
||||
text: "quoted body",
|
||||
senderLabel: "Molty",
|
||||
sourceMessageId: "transcript-abc",
|
||||
},
|
||||
});
|
||||
|
||||
const send = handleSendChat(host);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(host.chatQueue[0]?.text).toBe("continue");
|
||||
expect(host.chatQueue[0]?.replyToId).toBe("transcript-abc");
|
||||
|
||||
sent.resolve({ runId: host.chatQueue[0]?.sendRunId, status: "started" });
|
||||
await send;
|
||||
|
||||
const sendCall = request.mock.calls.find(([method]) => method === "chat.send");
|
||||
expect(sendCall?.[1]).toMatchObject({ message: "continue", replyToId: "transcript-abc" });
|
||||
expect(host.chatReplyTarget).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps reply state when chat.send fails before acceptance", async () => {
|
||||
const request = vi.fn((method: string) => {
|
||||
if (method === "chat.send") {
|
||||
|
||||
@@ -135,7 +135,12 @@ export type ChatHost = ChatInputHistoryState &
|
||||
/** Prepared from the browser override and current Gateway effective queue mode. */
|
||||
chatFollowUpMode?: ControlUiFollowUpMode;
|
||||
/** Selected message to reply to (right-click / keyboard shortcut). */
|
||||
chatReplyTarget?: { messageId: string; text: string; senderLabel?: string | null } | null;
|
||||
chatReplyTarget?: {
|
||||
messageId: string;
|
||||
text: string;
|
||||
senderLabel?: string | null;
|
||||
sourceMessageId?: string | null;
|
||||
} | null;
|
||||
/** Placeholder for an in-flight /btw side question awaiting chat.side_result. */
|
||||
chatSideResultPending?: ChatSideResultPending | null;
|
||||
/** Retired/handled BTW run ids whose late events must not reach the transcript. */
|
||||
@@ -262,6 +267,7 @@ async function requestChatSend(
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
queueMode?: QueueMode;
|
||||
replyToId?: string;
|
||||
},
|
||||
): Promise<ChatSendAck> {
|
||||
const routing = resolveChatSendRouting(state, params);
|
||||
@@ -277,6 +283,7 @@ async function requestChatSend(
|
||||
...(controlUiReconnectResume ? { __controlUiReconnectResume: true } : {}),
|
||||
message: params.message,
|
||||
deliver: false,
|
||||
...(params.replyToId ? { replyToId: params.replyToId } : {}),
|
||||
...(params.queueMode ? { queueMode: params.queueMode } : {}),
|
||||
idempotencyKey: params.runId,
|
||||
attachments: buildChatApiAttachments(params.attachments),
|
||||
@@ -440,6 +447,7 @@ function enqueuePendingSendMessage(
|
||||
submittedAtMs = controlUiNowMs(),
|
||||
sendState?: ChatQueueItem["sendState"],
|
||||
skillWorkshopRevision?: ChatQueueSkillWorkshopRevision,
|
||||
replyToId?: string,
|
||||
): ChatQueueItem | null {
|
||||
const trimmed = text.trim();
|
||||
const hasAttachments = Boolean(attachments && attachments.length > 0);
|
||||
@@ -459,6 +467,7 @@ function enqueuePendingSendMessage(
|
||||
sessionKey: host.sessionKey,
|
||||
agentId: scopedAgentIdForSession(host, host.sessionKey),
|
||||
...(skillWorkshopRevision ? { skillWorkshopRevision } : {}),
|
||||
...(replyToId ? { replyToId } : {}),
|
||||
};
|
||||
host.chatQueue = [...host.chatQueue, pending];
|
||||
recordChatSendTiming(host, pending, "pending-visible", submittedAtMs);
|
||||
@@ -835,6 +844,7 @@ async function sendQueuedChatMessage(
|
||||
runId,
|
||||
sessionKey,
|
||||
agentId: prepared.agentId,
|
||||
...(prepared.replyToId ? { replyToId: prepared.replyToId } : {}),
|
||||
});
|
||||
updateChatSendAckTiming(host, runId, ack, sendingItem, requestStartedAtMs);
|
||||
recordChatSendTiming(host, sendingItem, "ack", sendingItem.sendSubmittedAtMs, {
|
||||
@@ -2357,7 +2367,11 @@ export async function handleSendChat(
|
||||
}
|
||||
|
||||
const replyTarget = host.chatReplyTarget;
|
||||
const effectiveMessage = replyTarget ? prependReplyQuote(message, replyTarget) : message;
|
||||
// Persisted transcript ids ride chat.send as replyToId so the Gateway can
|
||||
// hydrate reply context like Discord; synthetic ids fall back to a quote.
|
||||
const replyToId = replyTarget?.sourceMessageId?.trim() || undefined;
|
||||
const effectiveMessage =
|
||||
replyTarget && !replyToId ? prependReplyQuote(message, replyTarget) : message;
|
||||
|
||||
const refreshSessions = shouldInterpretChatCommands && isChatResetCommand(message);
|
||||
const submitKey = chatSubmitKey(
|
||||
@@ -2392,6 +2406,7 @@ export async function handleSendChat(
|
||||
submittedAtMs,
|
||||
initialSendState,
|
||||
skillWorkshopRevision,
|
||||
replyToId,
|
||||
);
|
||||
if (!queued) {
|
||||
return;
|
||||
|
||||
@@ -196,9 +196,19 @@ export type ChatProps = {
|
||||
onChatScroll?: (event: Event) => void;
|
||||
basePath?: string;
|
||||
composerControls?: TemplateResult | typeof nothing;
|
||||
replyTarget?: { messageId: string; text: string; senderLabel?: string | null } | null;
|
||||
replyTarget?: {
|
||||
messageId: string;
|
||||
text: string;
|
||||
senderLabel?: string | null;
|
||||
sourceMessageId?: string | null;
|
||||
} | null;
|
||||
onClearReply?: () => void;
|
||||
onSetReply?: (target: { messageId: string; text: string; senderLabel?: string | null }) => void;
|
||||
onSetReply?: (target: {
|
||||
messageId: string;
|
||||
text: string;
|
||||
senderLabel?: string | null;
|
||||
sourceMessageId?: string | null;
|
||||
}) => void;
|
||||
onRewindMessage?: (entryId: string) => Promise<boolean> | boolean;
|
||||
onForkMessage?: (entryId: string) => Promise<void> | void;
|
||||
sessionWorkspace?: SessionWorkspaceProps;
|
||||
|
||||
@@ -108,7 +108,12 @@ type ChatComposerProps = {
|
||||
followUpMode?: ControlUiFollowUpMode;
|
||||
attachments?: ChatAttachment[];
|
||||
getAttachments?: () => ChatAttachment[];
|
||||
replyTarget?: { messageId: string; text: string; senderLabel?: string | null } | null;
|
||||
replyTarget?: {
|
||||
messageId: string;
|
||||
text: string;
|
||||
senderLabel?: string | null;
|
||||
sourceMessageId?: string | null;
|
||||
} | null;
|
||||
realtimeTalkActive?: boolean;
|
||||
realtimeTalkStatus?: RealtimeTalkStatus;
|
||||
realtimeTalkDetail?: string | null;
|
||||
|
||||
@@ -81,6 +81,8 @@ type ReplyTarget = {
|
||||
messageId: string;
|
||||
text: string;
|
||||
senderLabel?: string | null;
|
||||
/** Persisted transcript id; when present chat.send carries it as replyToId. */
|
||||
sourceMessageId?: string | null;
|
||||
};
|
||||
|
||||
type ChatThreadState = {
|
||||
@@ -810,7 +812,12 @@ function handleChatContextMenu(event: MouseEvent, props: ChatThreadProps) {
|
||||
const messageId =
|
||||
(bubble as HTMLElement).dataset.messageId?.trim() || stableReplyMessageId(senderLabel, text);
|
||||
const replyButton = createReplyContextMenuButton(() => {
|
||||
props.onSetReply?.({ messageId, text, senderLabel });
|
||||
props.onSetReply?.({
|
||||
messageId,
|
||||
text,
|
||||
senderLabel,
|
||||
...(entryId ? { sourceMessageId: entryId } : {}),
|
||||
});
|
||||
removeReplyContextMenu();
|
||||
props.onFocusComposer?.();
|
||||
});
|
||||
|
||||
@@ -784,6 +784,7 @@ function serializeQueueItem(item: ChatQueueItem): ChatQueueItem | null {
|
||||
...(item.kind === "queued" || item.kind === "steered" ? { kind: item.kind } : {}),
|
||||
...(attachments.length ? { attachments: attachments as ChatAttachment[] } : {}),
|
||||
...(typeof item.refreshSessions === "boolean" ? { refreshSessions: item.refreshSessions } : {}),
|
||||
...(item.replyToId ? { replyToId: item.replyToId } : {}),
|
||||
...(item.localCommandArgs ? { localCommandArgs: item.localCommandArgs } : {}),
|
||||
...(item.localCommandName ? { localCommandName: item.localCommandName } : {}),
|
||||
...(item.sessionKey ? { sessionKey: item.sessionKey } : {}),
|
||||
@@ -828,6 +829,10 @@ function normalizeQueueItem(value: unknown): ChatQueueItem | null {
|
||||
if (refreshSessions !== undefined) {
|
||||
item.refreshSessions = refreshSessions;
|
||||
}
|
||||
const replyToId = normalizeOptionalString(entry.replyToId);
|
||||
if (replyToId) {
|
||||
item.replyToId = replyToId;
|
||||
}
|
||||
if (
|
||||
entry.sendState === "failed" ||
|
||||
entry.sendState === "unconfirmed" ||
|
||||
|
||||
Reference in New Issue
Block a user