From c3b190dfc34024decead8957a3dcc0f761c97cc1 Mon Sep 17 00:00:00 2001
From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com>
Date: Tue, 8 Sep 2026 13:55:38 -0400
Subject: [PATCH 1/9] Add intent-bound AI actions to Content comments
---
.changeset/scoped-action-context.md | 5 +
.../core/src/agent/production-agent.spec.ts | 151 ++++-
packages/core/src/agent/production-agent.ts | 81 ++-
packages/core/src/agent/types.ts | 116 ++++
.../src/client/AssistantChat.display.spec.ts | 164 +++++
packages/core/src/client/AssistantChat.tsx | 223 ++++++-
.../core/src/client/MultiTabAssistantChat.tsx | 9 +-
.../src/client/agent-chat-adapter.spec.ts | 41 ++
.../core/src/client/agent-chat-adapter.ts | 22 +-
packages/core/src/client/agent-chat.spec.ts | 37 ++
packages/core/src/client/agent-chat.ts | 27 +-
.../review/suggestions/actions.replay.spec.ts | 238 ++++++++
.../core/src/review/suggestions/actions.ts | 126 +++-
.../core/src/review/suggestions/store.spec.ts | 23 +
packages/core/src/review/suggestions/store.ts | 31 +-
.../server/agent-chat-plugin.surface.spec.ts | 2 +-
packages/core/src/server/agent-chat-plugin.ts | 6 +
packages/core/src/server/index.ts | 3 +
packages/core/src/server/request-context.ts | 8 +-
templates/content/actions/add-comment.test.ts | 131 +++-
templates/content/actions/add-comment.ts | 284 ++++++---
.../actions/apply-comment-ai-request.ts | 151 +++++
.../content/actions/comment-ai-flow.test.ts | 425 +++++++++++++
.../actions/create-comment-ai-suggestion.ts | 119 ++++
.../content/actions/get-comment-ai-context.ts | 62 ++
.../actions/list-comment-ai-requests.ts | 14 +
templates/content/actions/list-comments.ts | 14 +-
.../actions/reply-to-comment-ai-request.ts | 99 +++
.../actions/start-comment-ai-request.ts | 21 +
.../app/components/editor/CommentsSidebar.tsx | 132 +++-
.../app/components/editor/DocumentEditor.tsx | 23 +
.../app/components/editor/comment-ai.test.tsx | 272 +++++++++
.../app/components/editor/comment-ai.tsx | 313 ++++++++++
.../app/hooks/content-action-refresh.ts | 66 +-
templates/content/app/hooks/use-comments.ts | 12 +-
.../content/app/hooks/use-db-sync.spec.ts | 147 +++++
templates/content/app/i18n-data.ts | 178 ++++++
templates/content/app/i18n/zh-TW.ts | 16 +
.../content.comment.page-owned.md | 12 +
templates/content/server/db/schema.ts | 37 ++
.../server/lib/comment-ai-progress.test.ts | 149 +++++
.../content/server/lib/comment-ai.spec.ts | 213 +++++++
templates/content/server/lib/comment-ai.ts | 576 ++++++++++++++++++
.../content/server/plugins/agent-chat.ts | 3 +
templates/content/server/plugins/db.ts | 23 +
templates/content/shared/comment-ai.ts | 37 ++
46 files changed, 4641 insertions(+), 201 deletions(-)
create mode 100644 .changeset/scoped-action-context.md
create mode 100644 packages/core/src/review/suggestions/actions.replay.spec.ts
create mode 100644 templates/content/actions/apply-comment-ai-request.ts
create mode 100644 templates/content/actions/comment-ai-flow.test.ts
create mode 100644 templates/content/actions/create-comment-ai-suggestion.ts
create mode 100644 templates/content/actions/get-comment-ai-context.ts
create mode 100644 templates/content/actions/list-comment-ai-requests.ts
create mode 100644 templates/content/actions/reply-to-comment-ai-request.ts
create mode 100644 templates/content/actions/start-comment-ai-request.ts
create mode 100644 templates/content/app/components/editor/comment-ai.test.tsx
create mode 100644 templates/content/app/components/editor/comment-ai.tsx
create mode 100644 templates/content/server/lib/comment-ai-progress.test.ts
create mode 100644 templates/content/server/lib/comment-ai.spec.ts
create mode 100644 templates/content/server/lib/comment-ai.ts
create mode 100644 templates/content/shared/comment-ai.ts
diff --git a/.changeset/scoped-action-context.md b/.changeset/scoped-action-context.md
new file mode 100644
index 00000000000..cc327a7f96f
--- /dev/null
+++ b/.changeset/scoped-action-context.md
@@ -0,0 +1,5 @@
+---
+"@agent-native/core": minor
+---
+
+Carry bounded, server-resolved action scope through agent chat runs and durable continuations. Keyed suggestion retries now verify the complete immutable request and return the original suggestion without rerunning mutable proposal validation.
diff --git a/packages/core/src/agent/production-agent.spec.ts b/packages/core/src/agent/production-agent.spec.ts
index 9abe5342d0b..526d7da84fa 100644
--- a/packages/core/src/agent/production-agent.spec.ts
+++ b/packages/core/src/agent/production-agent.spec.ts
@@ -1625,6 +1625,7 @@ describe("resolveAgentOwnerEmail", () => {
describe("createProductionAgentHandler", () => {
it("limits each request to the action names returned by resolveActionSurface", async () => {
const seenTools: string[][] = [];
+ const seenScopes: unknown[] = [];
const lifecycle: string[] = [];
const engine: AgentEngine = {
name: "test",
@@ -1641,6 +1642,7 @@ describe("createProductionAgentHandler", () => {
async *stream(opts): AsyncIterable {
lifecycle.push("stream");
seenTools.push(opts.tools.map((tool) => tool.name));
+ seenScopes.push(getRequestRunContext()?.actionScope);
yield {
type: "assistant-content",
parts: [{ type: "text", text: "done" }],
@@ -1652,22 +1654,31 @@ describe("createProductionAgentHandler", () => {
systemPrompt: "Test",
engine,
actions: {
- allowed: actionEntry({}),
+ allowed: { ...actionEntry({}), deferLoading: true },
denied: actionEntry({}),
"tool-search": actionEntry({}),
},
+ initialToolNames: ["denied"],
prepareRequest: async () => {
lifecycle.push("prepare");
},
- resolveActionSurface: async ({ threadId, availableActionNames }) => {
+ resolveActionSurface: async ({
+ threadId,
+ actionScope,
+ availableActionNames,
+ }) => {
lifecycle.push("surface");
expect(threadId).toBe("thread-allowed");
+ expect(actionScope).toEqual({
+ kind: "content-comment-ai",
+ requestId: "request-1",
+ });
expect(availableActionNames).toEqual([
"allowed",
"denied",
"tool-search",
]);
- return { allowedActionNames: ["allowed"] };
+ return { allowedActionNames: ["allowed"], actionScope };
},
});
const event = mockEvent(
@@ -1677,6 +1688,10 @@ describe("createProductionAgentHandler", () => {
body: JSON.stringify({
message: "Use the configured agent",
threadId: "thread-allowed",
+ actionScope: {
+ kind: "content-comment-ai",
+ requestId: "request-1",
+ },
}),
}),
);
@@ -1693,10 +1708,90 @@ describe("createProductionAgentHandler", () => {
await vi.waitFor(() => {
expect(seenTools).toEqual([["allowed"]]);
});
+ expect(seenScopes).toEqual([
+ { kind: "content-comment-ai", requestId: "request-1" },
+ ]);
expect(lifecycle).toEqual(["prepare", "surface", "stream"]);
expect(getRequestRunContext()).toBeUndefined();
});
+ it("rejects invalid action scopes before invoking the resolver", async () => {
+ const resolver = vi.fn(async () => ({
+ allowedActionNames: ["allowed"],
+ actionScope: {},
+ }));
+ const handler = createProductionAgentHandler({
+ systemPrompt: "Test",
+ engine: {
+ name: "test",
+ label: "Test",
+ defaultModel: "test-model",
+ supportedModels: ["test-model"],
+ capabilities: {
+ thinking: false,
+ promptCaching: false,
+ vision: false,
+ computerUse: false,
+ parallelToolCalls: false,
+ },
+ async *stream(): AsyncIterable {
+ yield { type: "stop", reason: "end_turn" };
+ },
+ },
+ actions: { allowed: actionEntry({}) },
+ resolveActionSurface: resolver,
+ });
+ const event = mockEvent(
+ new Request("http://app.example.com/_agent-native/agent-chat", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ message: "Run",
+ actionScope: { value: "x".repeat(9_000) },
+ }),
+ }),
+ );
+
+ const response = await runWithRequestContext(
+ { userEmail: "owner@example.com", run: {} },
+ () => handler(event),
+ );
+
+ expect(response).toEqual({
+ error: "actionScope must be at most 8192 bytes",
+ });
+ expect(resolver).not.toHaveBeenCalled();
+ });
+
+ it("rejects a scoped request when no action-surface resolver is configured", async () => {
+ const handler = createProductionAgentHandler({
+ systemPrompt: "Test",
+ actions: { allowed: actionEntry({}) },
+ });
+ const event = mockEvent(
+ new Request("http://app.example.com/_agent-native/agent-chat", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ message: "Run",
+ actionScope: {
+ kind: "content-comment-ai",
+ requestId: "request-1",
+ },
+ }),
+ }),
+ );
+
+ const response = await runWithRequestContext(
+ { userEmail: "owner@example.com", run: {} },
+ () => handler(event),
+ );
+
+ expect(response).toEqual({
+ error: "actionScope requires resolveActionSurface",
+ });
+ });
+
it("uses the normal initial tool surface when the resolver selects the default", async () => {
const seenTools: string[][] = [];
const engine: AgentEngine = {
@@ -2000,8 +2095,16 @@ describe("filterActionsByAllowedNames", () => {
expect(
normalizeAgentActionSurfaceResolution({
allowedActionNames: ["allowed", "allowed"],
+ actionScope: { kind: "content-comment-ai", requestId: "request-1" },
}),
- ).toEqual({ mode: "allowlist", allowedActionNames: ["allowed"] });
+ ).toEqual({
+ mode: "allowlist",
+ allowedActionNames: ["allowed"],
+ actionScope: {
+ kind: "content-comment-ai",
+ requestId: "request-1",
+ },
+ });
expect(() =>
normalizeAgentActionSurfaceResolution({
mode: "default",
@@ -2027,6 +2130,12 @@ describe("filterActionsByAllowedNames", () => {
allowedActionNames: "allowed",
}),
).toThrow("resolveActionSurface returned an invalid action surface");
+ expect(() =>
+ normalizeAgentActionSurfaceResolution({
+ allowedActionNames: ["allowed"],
+ actionScope: { value: "x".repeat(9_000) },
+ }),
+ ).toThrow("actionScope must be at most 8192 bytes");
});
it("treats an explicit empty allowlist as no actions", () => {
@@ -2114,6 +2223,40 @@ describe("filterActionsByAllowedNames", () => {
"__resolvedActionSurface",
),
).toEqual({ orgId: null, allowedActionNames: ["allowed"] });
+ expect(
+ readPersistedActionSurface(
+ {
+ __resolvedActionSurface: {
+ orgId: "org-123",
+ allowedActionNames: ["allowed"],
+ actionScope: {
+ kind: "content-comment-ai",
+ requestId: "request-1",
+ },
+ },
+ },
+ "__resolvedActionSurface",
+ ),
+ ).toEqual({
+ orgId: "org-123",
+ allowedActionNames: ["allowed"],
+ actionScope: {
+ kind: "content-comment-ai",
+ requestId: "request-1",
+ },
+ });
+ expect(
+ readPersistedActionSurface(
+ {
+ __resolvedActionSurface: {
+ orgId: "org-123",
+ allowedActionNames: ["allowed"],
+ actionScope: { value: "x".repeat(9_000) },
+ },
+ },
+ "__resolvedActionSurface",
+ ),
+ ).toEqual({ orgId: null, allowedActionNames: [] });
expect(
readPersistedActionSurface(
{
diff --git a/packages/core/src/agent/production-agent.ts b/packages/core/src/agent/production-agent.ts
index 6b2633f9e37..5d46bf9606b 100644
--- a/packages/core/src/agent/production-agent.ts
+++ b/packages/core/src/agent/production-agent.ts
@@ -233,7 +233,9 @@ import {
searchToolRegistry,
TOOL_SEARCH_ACTION_NAME,
} from "./tool-search.js";
-import type {
+import {
+ normalizeAgentActionScope,
+ type AgentActionScope,
ActionTool,
AgentNativeJsonSchema,
AgentChatAttachment,
@@ -869,6 +871,7 @@ export type AgentExecutionMode = "act" | "plan";
export interface AgentActionSurface {
allowedActionNames: readonly string[];
+ actionScope?: AgentActionScope;
}
export interface DefaultAgentActionSurface {
@@ -881,7 +884,11 @@ export type AgentActionSurfaceResolution =
type NormalizedAgentActionSurface =
| DefaultAgentActionSurface
- | { mode: "allowlist"; allowedActionNames: string[] };
+ | {
+ mode: "allowlist";
+ allowedActionNames: string[];
+ actionScope?: AgentActionScope;
+ };
export interface AgentActionSurfaceDetails {
event: any;
@@ -890,6 +897,7 @@ export interface AgentActionSurfaceDetails {
threadId?: string;
mode: AgentExecutionMode;
internalContinuation: boolean;
+ actionScope?: Readonly;
availableActionNames: readonly string[];
}
@@ -942,6 +950,9 @@ export function normalizeAgentActionSurfaceResolution(
return {
mode: "allowlist",
allowedActionNames: [...new Set(allowedActionNames)],
+ ...(hasOwn(value, "actionScope")
+ ? { actionScope: normalizeAgentActionScope(value.actionScope) }
+ : {}),
};
}
@@ -949,6 +960,7 @@ export type PersistedActionSurface =
| {
orgId: string | null;
allowedActionNames: string[];
+ actionScope?: AgentActionScope;
}
| {
orgId: string | null;
@@ -980,7 +992,18 @@ export function readPersistedActionSurface(
return { orgId: null, allowedActionNames: [] };
}
const allowedActionNames = readPersistedAllowedActionNames(surface) ?? [];
- return { orgId, allowedActionNames };
+ if (!hasOwn(surface, "actionScope")) return { orgId, allowedActionNames };
+ try {
+ return {
+ orgId,
+ allowedActionNames,
+ actionScope: normalizeAgentActionScope(
+ (surface as Record).actionScope,
+ ),
+ };
+ } catch {
+ return { orgId: null, allowedActionNames: [] };
+ }
}
export function filterActionsByAllowedNames(
@@ -8945,6 +8968,22 @@ export function createProductionAgentHandler(
delete body[AGENT_CHAT_BACKGROUND_RUN_FIELD];
delete body.__resolvedActionSurface;
}
+ let requestedActionScope: AgentActionScope | undefined;
+ if (hasOwn(body, "actionScope")) {
+ try {
+ requestedActionScope = normalizeAgentActionScope(body.actionScope);
+ body.actionScope = requestedActionScope;
+ } catch (error) {
+ setResponseStatus(event, 400);
+ return {
+ error: error instanceof Error ? error.message : "Invalid actionScope",
+ };
+ }
+ }
+ if (requestedActionScope && !options.resolveActionSurface) {
+ setResponseStatus(event, 400);
+ return { error: "actionScope requires resolveActionSurface" };
+ }
// DIAGNOSTIC-ONLY: progressive per-stage hang localizer for the bg worker.
// The worker's runId is available EARLY on the marker (the general `runId`
// var resolves much later), so capture it now and emit the LAST setup stage
@@ -9126,6 +9165,14 @@ export function createProductionAgentHandler(
const persistedSurface = isBackgroundWorker
? readPersistedActionSurface(body, "__resolvedActionSurface")
: undefined;
+ if (
+ isBackgroundWorker &&
+ requestedActionScope &&
+ (!persistedSurface || !("actionScope" in persistedSurface))
+ ) {
+ setResponseStatus(event, 400);
+ return { error: "Resolved actionScope is required for continuation" };
+ }
const surface =
persistedSurface !== undefined
? persistedSurface
@@ -9136,13 +9183,27 @@ export function createProductionAgentHandler(
threadId,
mode: requestMode,
internalContinuation: Boolean(internalContinuation),
+ ...(requestedActionScope
+ ? { actionScope: requestedActionScope }
+ : {}),
availableActionNames: Object.keys(availableRequestActions),
});
const normalizedSurface = normalizeAgentActionSurfaceResolution(surface);
+ if (
+ requestedActionScope &&
+ (normalizedSurface.mode === "default" || !normalizedSurface.actionScope)
+ ) {
+ throw new Error(
+ "resolveActionSurface must return actionScope for a scoped request",
+ );
+ }
const runCtx = ensureRequestRunContext();
if (normalizedSurface.mode === "default") {
useDefaultRequestActionSurface = true;
- if (runCtx) delete runCtx.allowedActionNames;
+ if (runCtx) {
+ delete runCtx.allowedActionNames;
+ delete runCtx.actionScope;
+ }
if (!isBackgroundWorker) {
body.__resolvedActionSurface = {
orgId: getRequestOrgId() ?? null,
@@ -9161,11 +9222,21 @@ export function createProductionAgentHandler(
);
}
const allowedNames = Object.keys(surfacedRequestActions);
- if (runCtx) runCtx.allowedActionNames = allowedNames;
+ if (runCtx) {
+ runCtx.allowedActionNames = allowedNames;
+ if (normalizedSurface.actionScope) {
+ runCtx.actionScope = normalizedSurface.actionScope;
+ } else {
+ delete runCtx.actionScope;
+ }
+ }
if (!isBackgroundWorker) {
body.__resolvedActionSurface = {
orgId: getRequestOrgId() ?? null,
allowedActionNames: allowedNames,
+ ...(normalizedSurface.actionScope
+ ? { actionScope: normalizedSurface.actionScope }
+ : {}),
};
}
}
diff --git a/packages/core/src/agent/types.ts b/packages/core/src/agent/types.ts
index c128ddc4a27..a74d363b91d 100644
--- a/packages/core/src/agent/types.ts
+++ b/packages/core/src/agent/types.ts
@@ -44,6 +44,119 @@ export interface AgentMessage {
content: string;
}
+export type AgentActionScopeJsonValue =
+ | null
+ | boolean
+ | number
+ | string
+ | AgentActionScopeJsonValue[]
+ | { [key: string]: AgentActionScopeJsonValue };
+
+/** Opaque, request-specific data interpreted by an app's scoped actions. */
+export type AgentActionScope = Record;
+
+export const AGENT_ACTION_SCOPE_MAX_BYTES = 8 * 1024;
+const AGENT_ACTION_SCOPE_MAX_DEPTH = 8;
+const AGENT_ACTION_SCOPE_MAX_NODES = 256;
+
+function cloneAgentActionScopeValue(
+ value: unknown,
+ depth: number,
+ state: { nodes: number },
+): AgentActionScopeJsonValue {
+ state.nodes += 1;
+ if (
+ depth > AGENT_ACTION_SCOPE_MAX_DEPTH ||
+ state.nodes > AGENT_ACTION_SCOPE_MAX_NODES
+ ) {
+ throw new TypeError("actionScope exceeds its structural limits");
+ }
+ if (
+ value === null ||
+ typeof value === "boolean" ||
+ typeof value === "string"
+ ) {
+ return value;
+ }
+ if (typeof value === "number") {
+ if (!Number.isFinite(value)) {
+ throw new TypeError("actionScope must contain only JSON values");
+ }
+ return value;
+ }
+ if (Array.isArray(value)) {
+ const keys = Reflect.ownKeys(value);
+ if (
+ keys.some(
+ (key) =>
+ typeof key !== "string" ||
+ (key !== "length" &&
+ (String(Number(key)) !== key || Number(key) >= value.length)),
+ ) ||
+ Object.keys(value).length !== value.length
+ ) {
+ throw new TypeError("actionScope must contain only JSON arrays");
+ }
+ return Array.from(value, (item) =>
+ cloneAgentActionScopeValue(item, depth + 1, state),
+ );
+ }
+ if (typeof value !== "object") {
+ throw new TypeError("actionScope must contain only JSON values");
+ }
+ const prototype = Object.getPrototypeOf(value);
+ if (prototype !== Object.prototype && prototype !== null) {
+ throw new TypeError("actionScope must contain only JSON objects");
+ }
+ const object = value as Record;
+ const keys = Reflect.ownKeys(object);
+ if (
+ keys.some((key) => {
+ if (typeof key !== "string") return true;
+ const descriptor = Object.getOwnPropertyDescriptor(object, key);
+ return !descriptor?.enumerable || !("value" in descriptor);
+ })
+ ) {
+ throw new TypeError("actionScope must contain only JSON values");
+ }
+ return Object.fromEntries(
+ Object.entries(object).map(([key, item]) => [
+ key,
+ cloneAgentActionScopeValue(item, depth + 1, state),
+ ]),
+ );
+}
+
+/** Validate and clone an untrusted action scope into bounded JSON data. */
+export function normalizeAgentActionScope(value: unknown): AgentActionScope {
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
+ throw new TypeError("actionScope must be a JSON object");
+ }
+ const cloned = cloneAgentActionScopeValue(value, 0, {
+ nodes: 0,
+ }) as AgentActionScope;
+ if (
+ new TextEncoder().encode(JSON.stringify(cloned)).byteLength >
+ AGENT_ACTION_SCOPE_MAX_BYTES
+ ) {
+ throw new TypeError(
+ `actionScope must be at most ${AGENT_ACTION_SCOPE_MAX_BYTES} bytes`,
+ );
+ }
+ return cloned;
+}
+
+export function tryNormalizeAgentActionScope(
+ value: unknown,
+): AgentActionScope | undefined {
+ try {
+ return normalizeAgentActionScope(value);
+ } catch (error) {
+ if (error instanceof TypeError) return undefined;
+ throw error;
+ }
+}
+
export type AgentChatStructuredContentPart =
| { type: "text"; text: string }
| {
@@ -178,6 +291,8 @@ export interface AgentChatHarnessRequest {
export interface AgentChatRequest {
message: string;
+ /** Requested app-defined action scope. Authorization is resolved server-side. */
+ actionScope?: AgentActionScope;
/** Stable identity of a durable queued message, used to reject replayed delivery. */
queuedMessageId?: string;
/**
@@ -261,6 +376,7 @@ export interface AgentChatRequest {
| {
orgId: string | null;
allowedActionNames: string[];
+ actionScope?: AgentActionScope;
}
| {
orgId: string | null;
diff --git a/packages/core/src/client/AssistantChat.display.spec.ts b/packages/core/src/client/AssistantChat.display.spec.ts
index 67217a4cf36..6a31adacec9 100644
--- a/packages/core/src/client/AssistantChat.display.spec.ts
+++ b/packages/core/src/client/AssistantChat.display.spec.ts
@@ -28,6 +28,7 @@ import {
assistantChatAutoscrollStatusKey,
assistantUiMessageListStructureKey,
assistantUiRecoverableRenderErrorKind,
+ approvalProtocolContinuationContext,
createUserMessageRunConfig,
dedupeReconnectContentAgainstMessages,
shouldShowReconnectOverlay,
@@ -38,7 +39,9 @@ import {
isAssistantUiStaleIndexError,
installAssistantUiMessageRepositoryRecovery,
latestNonRecoveryUserMessageText,
+ latestProtocolContinuationContext,
matchesUserStoppedRun,
+ protocolContinuationContext,
reconnectActivityFallbackContent,
reconnectProgressTimedOut,
resolveAssistantChatRunningState,
@@ -577,6 +580,131 @@ describe("createUserMessageRunConfig model snapshot", () => {
agentNativeQueuedMessageId: "queued-legacy",
});
});
+
+ it("preserves the action scope in queued run configuration", () => {
+ const options = createUserMessageRunConfig(
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ "queued-scope",
+ undefined,
+ undefined,
+ "turn-scope",
+ undefined,
+ { kind: "content-comment-ai", requestId: "request-1" },
+ );
+
+ expect(options.runConfig?.custom).toMatchObject({
+ agentNativeQueuedMessageId: "queued-scope",
+ actionScope: {
+ kind: "content-comment-ai",
+ requestId: "request-1",
+ },
+ });
+ expect(options.metadata?.custom).toMatchObject({
+ turnId: "turn-scope",
+ actionScope: {
+ kind: "content-comment-ai",
+ requestId: "request-1",
+ },
+ });
+ });
+});
+
+describe("scoped protocol continuations", () => {
+ const scopedUser = {
+ role: "user",
+ metadata: {
+ custom: {
+ turnId: "turn-scoped",
+ actionScope: {
+ kind: "content-comment-ai",
+ requestId: "request-1",
+ },
+ },
+ },
+ content: [{ type: "text", text: "Draft a reply" }],
+ };
+
+ it("restores scope only for the matching turn", () => {
+ expect(protocolContinuationContext([scopedUser], "turn-scoped")).toEqual({
+ turnId: "turn-scoped",
+ actionScope: {
+ kind: "content-comment-ai",
+ requestId: "request-1",
+ },
+ });
+ expect(protocolContinuationContext([scopedUser], "turn-other")).toEqual({
+ turnId: "turn-other",
+ });
+ });
+
+ it("does not leak an older scope into an unrelated newer turn", () => {
+ const unscopedAssistant = {
+ role: "assistant",
+ metadata: { custom: { turnId: "turn-unscoped" } },
+ content: [{ type: "text", text: "Done" }],
+ };
+
+ expect(
+ latestProtocolContinuationContext([scopedUser, unscopedAssistant]),
+ ).toEqual({ turnId: "turn-unscoped" });
+ });
+
+ it("binds approval scope to the message with that approval", () => {
+ const approvalMessage = {
+ role: "assistant",
+ metadata: { custom: { turnId: "turn-scoped" } },
+ content: [
+ {
+ type: "tool-call",
+ approval: { approvalKey: "approval-scoped" },
+ },
+ ],
+ };
+ const laterUnscoped = {
+ role: "assistant",
+ metadata: { custom: { turnId: "turn-unscoped" } },
+ content: [{ type: "text", text: "Later message" }],
+ };
+
+ expect(
+ approvalProtocolContinuationContext(
+ [scopedUser, approvalMessage, laterUnscoped],
+ "approval-scoped",
+ ),
+ ).toEqual({
+ turnId: "turn-scoped",
+ actionScope: {
+ kind: "content-comment-ai",
+ requestId: "request-1",
+ },
+ });
+ expect(
+ approvalProtocolContinuationContext(
+ [scopedUser, approvalMessage, laterUnscoped],
+ "approval-other",
+ ),
+ ).toEqual({});
+ });
+
+ it("fails closed when stored scope metadata is malformed", () => {
+ expect(() =>
+ protocolContinuationContext(
+ [
+ {
+ ...scopedUser,
+ metadata: {
+ custom: { turnId: "turn-scoped", actionScope: [] },
+ },
+ },
+ ],
+ "turn-scoped",
+ ),
+ ).toThrow("actionScope must be a JSON object");
+ });
});
describe("dedupeReconnectContentAgainstMessages", () => {
@@ -1642,6 +1770,42 @@ describe("tool approval continuation", () => {
expect(approvalSource).toContain(
"true, // hideUserMessage: this is a protocol continuation, not a new prompt",
);
+ expect(approvalSource).toContain("approvalProtocolContinuationContext(");
+ expect(approvalSource).toContain("continuation.actionScope");
+ });
+});
+
+describe("protocol continuation scope wiring", () => {
+ it("carries the originating scope through reconnect and recovery controls", () => {
+ const source = readFileSync("src/client/AssistantChat.tsx", {
+ encoding: "utf8",
+ });
+ const reconnectStart = source.indexOf(
+ "if (!pendingReconnectRecovery) return;",
+ );
+ const reconnectEnd = source.indexOf(
+ "const latestMessage =",
+ reconnectStart,
+ );
+ const controlsStart = source.indexOf(
+ "{visibleLoopLimit && !showRunningInUI && (",
+ );
+ const controlsEnd = source.indexOf(
+ "{showReconnectOverlay &&",
+ controlsStart,
+ );
+
+ expect(source.slice(reconnectStart, reconnectEnd)).toContain(
+ "continuation.actionScope",
+ );
+ expect(source.slice(controlsStart, controlsEnd)).toContain(
+ "continuation.actionScope",
+ );
+ expect(
+ source
+ .slice(controlsStart, controlsEnd)
+ .match(/continuation\.actionScope/g),
+ ).toHaveLength(2);
});
});
diff --git a/packages/core/src/client/AssistantChat.tsx b/packages/core/src/client/AssistantChat.tsx
index 3d5d81a3a5a..25ae1533e72 100644
--- a/packages/core/src/client/AssistantChat.tsx
+++ b/packages/core/src/client/AssistantChat.tsx
@@ -47,7 +47,11 @@ import React, {
useImperativeHandle,
} from "react";
-import type { AgentChatAttachment } from "../agent/types.js";
+import {
+ normalizeAgentActionScope,
+ type AgentActionScope,
+ type AgentChatAttachment,
+} from "../agent/types.js";
import { createPollEngine } from "../shared/poll-engine.js";
import type { ReasoningEffort } from "../shared/reasoning-effort.js";
import type { ThinkingDisplay } from "../shared/thinking-display.js";
@@ -67,6 +71,7 @@ import {
import {
activeRunLooksAlive,
createAgentChatAdapter,
+ generateAgentChatTurnId,
hasInFlightToolCall,
type AgentChatSurfaceKind,
} from "./agent-chat-adapter.js";
@@ -310,6 +315,7 @@ export interface AssistantChatSendOptions {
submitMessageId?: string;
/** See `AgentChatMessage.usageLabel`. */
usageLabel?: string;
+ actionScope?: AgentActionScope;
}
export function createUserMessageRunConfig(
@@ -327,6 +333,7 @@ export function createUserMessageRunConfig(
},
turnId?: string,
usageLabel?: string,
+ actionScope?: AgentActionScope,
) {
const custom: {
references?: Reference[];
@@ -339,6 +346,7 @@ export function createUserMessageRunConfig(
effort?: ReasoningEffort;
turnId?: string;
usageLabel?: string;
+ actionScope?: AgentActionScope;
} = {};
if (modelSnapshot?.model) custom.model = modelSnapshot.model;
if (modelSnapshot?.engine) custom.engine = modelSnapshot.engine;
@@ -364,6 +372,9 @@ export function createUserMessageRunConfig(
if (usageLabel) {
custom.usageLabel = usageLabel;
}
+ if (actionScope) {
+ custom.actionScope = actionScope;
+ }
const options: {
runConfig?: { custom: typeof custom };
metadata?: {
@@ -371,13 +382,21 @@ export function createUserMessageRunConfig(
agentNativeRecoveryAction?: AgentRecoveryAction;
agentNativeHiddenUserMessage?: boolean;
agentNativeQueuedMessageId?: string;
+ turnId?: string;
+ actionScope?: AgentActionScope;
};
};
} = {};
if (Object.keys(custom).length > 0) {
options.runConfig = { custom };
}
- if (recoveryAction || hideUserMessage || queuedMessageId) {
+ if (
+ recoveryAction ||
+ hideUserMessage ||
+ queuedMessageId ||
+ turnId ||
+ actionScope
+ ) {
options.metadata = {
custom: {
...(recoveryAction
@@ -387,6 +406,8 @@ export function createUserMessageRunConfig(
...(queuedMessageId
? { agentNativeQueuedMessageId: queuedMessageId }
: {}),
+ ...(turnId ? { turnId } : {}),
+ ...(actionScope ? { actionScope } : {}),
},
};
}
@@ -1553,6 +1574,76 @@ const RECOVERY_USER_MESSAGE_PREFIXES = [
"Retry the previous request from a clean approach",
];
+function protocolMessageCustomMetadata(
+ message: unknown,
+): Record | undefined {
+ const metadata = (message as { metadata?: unknown })?.metadata;
+ if (!metadata || typeof metadata !== "object") return undefined;
+ const custom = (metadata as { custom?: unknown }).custom;
+ return custom && typeof custom === "object"
+ ? (custom as Record)
+ : undefined;
+}
+
+function protocolMessageTurnId(message: unknown): string | undefined {
+ const turnId = protocolMessageCustomMetadata(message)?.turnId;
+ return typeof turnId === "string" && turnId ? turnId : undefined;
+}
+
+export function protocolContinuationContext(
+ messages: readonly unknown[],
+ turnId: string | undefined,
+): { turnId?: string; actionScope?: AgentActionScope } {
+ if (!turnId) return {};
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
+ const message = messages[index];
+ if (protocolMessageTurnId(message) !== turnId) continue;
+ const custom = protocolMessageCustomMetadata(message);
+ if (!custom || !Object.hasOwn(custom, "actionScope")) continue;
+ return {
+ turnId,
+ actionScope: normalizeAgentActionScope(custom.actionScope),
+ };
+ }
+ return { turnId };
+}
+
+export function latestProtocolContinuationContext(
+ messages: readonly unknown[],
+): { turnId?: string; actionScope?: AgentActionScope } {
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
+ const turnId = protocolMessageTurnId(messages[index]);
+ if (turnId) return protocolContinuationContext(messages, turnId);
+ }
+ return {};
+}
+
+export function approvalProtocolContinuationContext(
+ messages: readonly unknown[],
+ approvalKey: string,
+): { turnId?: string; actionScope?: AgentActionScope } {
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
+ const message = messages[index] as { role?: unknown; content?: unknown };
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) {
+ continue;
+ }
+ const hasApproval = message.content.some((part) => {
+ if (!part || typeof part !== "object") return false;
+ const approval = (part as { approval?: unknown }).approval;
+ return (
+ approval !== null &&
+ typeof approval === "object" &&
+ (approval as { approvalKey?: unknown }).approvalKey === approvalKey
+ );
+ });
+ if (!hasApproval) continue;
+ const turnId = protocolMessageTurnId(message);
+ if (turnId) return protocolContinuationContext(messages, turnId);
+ return latestProtocolContinuationContext(messages.slice(0, index + 1));
+ }
+ return {};
+}
+
function getRecoveryActionMetadata(
message: unknown,
): AgentRecoveryAction | null {
@@ -1821,6 +1912,7 @@ type QueuedMessage = {
turnId?: string;
/** See `AgentChatMessage.usageLabel`. */
usageLabel?: string;
+ actionScope?: AgentActionScope;
/**
* Model/engine/effort snapshotted at enqueue time, for the same reason
* `requestMode` is: the picker is global and live, so a queue that flushes
@@ -4938,6 +5030,7 @@ const AssistantChatInner = forwardRef<
},
currentNext.turnId,
currentNext.usageLabel,
+ currentNext.actionScope,
).runConfig ?? {},
});
applyLocalQueuedMessages((prev) =>
@@ -4979,6 +5072,7 @@ const AssistantChatInner = forwardRef<
},
currentNext.turnId,
currentNext.usageLabel,
+ currentNext.actionScope,
),
} as Parameters[0]);
}
@@ -5381,6 +5475,7 @@ const AssistantChatInner = forwardRef<
},
message.turnId,
message.usageLabel,
+ message.actionScope,
),
startRun: false,
} as Parameters[0]);
@@ -5424,6 +5519,7 @@ const AssistantChatInner = forwardRef<
approvedToolCalls?: string[],
continuationTurnId?: string,
usageLabel?: string,
+ actionScope?: AgentActionScope,
) => {
if (isAgentChatSubmitCancelled(submitMessageId)) return;
const stoppedRunAtSubmitStart = userStoppedRunRef.current;
@@ -5582,6 +5678,9 @@ const AssistantChatInner = forwardRef<
engine: selectedEngine,
effort: selectedEffort,
};
+ const effectiveContinuationTurnId =
+ continuationTurnId ??
+ (actionScope ? generateAgentChatTurnId() : undefined);
if (isRunning && intent === "immediate") {
// Explicit interrupt path: abort the active server run, then let the
// auto-dequeue path append this message once the run is clear. Normal
@@ -5603,8 +5702,11 @@ const AssistantChatInner = forwardRef<
trackInRunsTray,
hideUserMessage,
approvedToolCalls,
- ...(continuationTurnId ? { turnId: continuationTurnId } : {}),
+ ...(effectiveContinuationTurnId
+ ? { turnId: effectiveContinuationTurnId }
+ : {}),
...(usageLabel ? { usageLabel } : {}),
+ ...(actionScope ? { actionScope } : {}),
...modelSnapshot,
},
]);
@@ -5627,8 +5729,11 @@ const AssistantChatInner = forwardRef<
trackInRunsTray,
hideUserMessage,
approvedToolCalls,
- ...(continuationTurnId ? { turnId: continuationTurnId } : {}),
+ ...(effectiveContinuationTurnId
+ ? { turnId: effectiveContinuationTurnId }
+ : {}),
...(usageLabel ? { usageLabel } : {}),
+ ...(actionScope ? { actionScope } : {}),
...modelSnapshot,
},
]);
@@ -5650,8 +5755,9 @@ const AssistantChatInner = forwardRef<
undefined,
hideUserMessage,
undefined,
- continuationTurnId,
+ effectiveContinuationTurnId,
usageLabel,
+ actionScope,
),
} as Parameters[0]);
} catch (error) {
@@ -5704,7 +5810,27 @@ const AssistantChatInner = forwardRef<
}
mcpResumeTimerRef.current = window.setTimeout(() => {
mcpResumeTimerRef.current = null;
- void addToQueue(request.message);
+ const continuation = latestProtocolContinuationContext(
+ messagesRef.current,
+ );
+ void addToQueue(
+ request.message,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ "queued",
+ undefined,
+ false,
+ false,
+ false,
+ false,
+ undefined,
+ undefined,
+ continuation.turnId,
+ undefined,
+ continuation.actionScope,
+ );
}, 0);
},
[addToQueue],
@@ -5737,6 +5863,9 @@ const AssistantChatInner = forwardRef<
setPendingReconnectRecovery((current) =>
current?.id === recovery.id ? null : current,
);
+ const continuation = recovery.turnId
+ ? protocolContinuationContext(messagesRef.current, recovery.turnId)
+ : latestProtocolContinuationContext(messagesRef.current);
void addToQueue(
recovery.message,
undefined,
@@ -5751,7 +5880,9 @@ const AssistantChatInner = forwardRef<
true,
undefined,
undefined,
- recovery.turnId,
+ continuation.turnId,
+ undefined,
+ continuation.actionScope,
);
}, 0);
return () => window.clearTimeout(timer);
@@ -5771,12 +5902,24 @@ const AssistantChatInner = forwardRef<
const handleImplementPlan = useCallback(() => {
if (!canImplementPlan) return false;
onExecModeChange?.("build");
+ const continuation = latestProtocolContinuationContext(messagesRef.current);
void addToQueue(
"Implement the plan.",
undefined,
undefined,
undefined,
"act",
+ "queued",
+ undefined,
+ false,
+ false,
+ false,
+ false,
+ undefined,
+ undefined,
+ continuation.turnId,
+ undefined,
+ continuation.actionScope,
);
return true;
}, [addToQueue, canImplementPlan, onExecModeChange]);
@@ -5809,6 +5952,7 @@ const AssistantChatInner = forwardRef<
undefined,
undefined,
options?.usageLabel,
+ options?.actionScope,
);
},
implementPlan() {
@@ -5841,6 +5985,9 @@ const AssistantChatInner = forwardRef<
recoveryAction: AgentRecoveryAction,
images?: string[],
) {
+ const continuation = latestProtocolContinuationContext(
+ messagesRef.current,
+ );
void addToQueue(
text,
images,
@@ -5849,6 +5996,15 @@ const AssistantChatInner = forwardRef<
undefined,
"queued",
recoveryAction,
+ false,
+ false,
+ false,
+ false,
+ undefined,
+ undefined,
+ continuation.turnId,
+ undefined,
+ continuation.actionScope,
);
},
queueMessage(text: string, images?: string[]) {
@@ -5996,6 +6152,10 @@ const AssistantChatInner = forwardRef<
);
const retryAfterRunError = useCallback(() => {
setRunErrorInfo(null);
+ const failedTurnId = runErrorInfo?.turnId ?? lastMessageRunError?.turnId;
+ const continuation = failedTurnId
+ ? protocolContinuationContext(messagesRef.current, failedTurnId)
+ : latestProtocolContinuationContext(messagesRef.current);
void addToQueue(
lastUserText
? `Retry the previous request from a clean approach. Do not rerun the exact same failed tool input unless the failure was transient or the user explicitly asked for an exact rerun. If a provider query failed because of schema, syntax, or type mismatch, diagnose the error and adjust the query first.\n\nOriginal request:\n\n${lastUserText}`
@@ -6006,8 +6166,17 @@ const AssistantChatInner = forwardRef<
undefined,
"queued",
"retry",
+ false,
+ false,
+ false,
+ false,
+ undefined,
+ undefined,
+ continuation.turnId,
+ undefined,
+ continuation.actionScope,
);
- }, [addToQueue, lastUserText]);
+ }, [addToQueue, lastMessageRunError?.turnId, lastUserText, runErrorInfo]);
const [missingKeyBouncePulse, setMissingKeyBouncePulse] = useState(0);
const bounceMissingKeySetup = useCallback(() => {
setMissingKeyBouncePulse((pulse) => pulse + 1);
@@ -6217,6 +6386,10 @@ const AssistantChatInner = forwardRef<
// queued messages (no hand-written fetch).
const approveToolCall = useCallback(
(approvalKey: string) => {
+ const continuation = approvalProtocolContinuationContext(
+ messagesRef.current,
+ approvalKey,
+ );
void addToQueue(
"Approved. Go ahead and run the requested action.", // i18n-ignore -- stable hidden agent instruction, not UI copy.
undefined,
@@ -6231,6 +6404,9 @@ const AssistantChatInner = forwardRef<
true, // hideUserMessage: this is a protocol continuation, not a new prompt
undefined,
[approvalKey],
+ continuation.turnId,
+ undefined,
+ continuation.actionScope,
);
},
[addToQueue],
@@ -6560,6 +6736,10 @@ const AssistantChatInner = forwardRef<
onContinue={() => {
setShowContinue(false);
setLoopLimitInfo(null);
+ const continuation =
+ latestProtocolContinuationContext(
+ messagesRef.current,
+ );
void addToQueue(
"Continue from where you left off.",
undefined,
@@ -6568,6 +6748,15 @@ const AssistantChatInner = forwardRef<
undefined,
"queued",
"continue",
+ false,
+ false,
+ false,
+ false,
+ undefined,
+ undefined,
+ continuation.turnId,
+ undefined,
+ continuation.actionScope,
);
}}
/>
@@ -6579,6 +6768,15 @@ const AssistantChatInner = forwardRef<
info={visibleRunError}
onContinue={() => {
setRunErrorInfo(null);
+ const continuation =
+ visibleRunError.turnId
+ ? protocolContinuationContext(
+ messagesRef.current,
+ visibleRunError.turnId,
+ )
+ : latestProtocolContinuationContext(
+ messagesRef.current,
+ );
void addToQueue(
RECONNECT_NO_PROGRESS_CONTINUE_MESSAGE,
undefined,
@@ -6587,6 +6785,15 @@ const AssistantChatInner = forwardRef<
undefined,
"queued",
"continue",
+ false,
+ false,
+ false,
+ false,
+ undefined,
+ undefined,
+ continuation.turnId,
+ undefined,
+ continuation.actionScope,
);
}}
onRetry={retryAfterRunError}
diff --git a/packages/core/src/client/MultiTabAssistantChat.tsx b/packages/core/src/client/MultiTabAssistantChat.tsx
index 64a41e6f7e7..6ffc37b99e0 100644
--- a/packages/core/src/client/MultiTabAssistantChat.tsx
+++ b/packages/core/src/client/MultiTabAssistantChat.tsx
@@ -13,7 +13,7 @@ import React, {
} from "react";
import { DEFAULT_MODEL } from "../agent/default-model.js";
-import type { AgentChatAttachment } from "../agent/types.js";
+import type { AgentActionScope, AgentChatAttachment } from "../agent/types.js";
import {
DEFAULT_REASONING_EFFORT,
isReasoningEffort,
@@ -108,6 +108,7 @@ interface PendingSend {
submitMessageId?: string;
/** See `AgentChatMessage.usageLabel`. */
usageLabel?: string;
+ actionScope?: AgentActionScope;
}
/**
@@ -134,7 +135,8 @@ function deliverPendingSend(ref: AssistantChatHandle, send: PendingSend): void {
send.requestMode ||
send.submitMessageId ||
send.attachments ||
- send.usageLabel
+ send.usageLabel ||
+ send.actionScope
) {
ref.sendMessage(send.message, send.images, {
...(send.trackInRunsTray ? { trackInRunsTray: true } : {}),
@@ -144,6 +146,7 @@ function deliverPendingSend(ref: AssistantChatHandle, send: PendingSend): void {
? { submitMessageId: send.submitMessageId }
: {}),
...(send.usageLabel ? { usageLabel: send.usageLabel } : {}),
+ ...(send.actionScope ? { actionScope: send.actionScope } : {}),
});
} else {
ref.sendMessage(send.message, send.images);
@@ -1937,6 +1940,7 @@ export function MultiTabAssistantChat({
attachments,
submitMessageId,
usageLabel,
+ actionScope,
} = parsed;
const requestedTabId = parsed.tabId;
const requestMode =
@@ -1971,6 +1975,7 @@ export function MultiTabAssistantChat({
...(requestMode ? { requestMode } : {}),
...(submitMessageId ? { submitMessageId } : {}),
...(usageLabel ? { usageLabel } : {}),
+ ...(actionScope ? { actionScope } : {}),
};
// Resolved once, up front, and carried with the send until a thread
diff --git a/packages/core/src/client/agent-chat-adapter.spec.ts b/packages/core/src/client/agent-chat-adapter.spec.ts
index f50b905b71d..309efaad912 100644
--- a/packages/core/src/client/agent-chat-adapter.spec.ts
+++ b/packages/core/src/client/agent-chat-adapter.spec.ts
@@ -2164,6 +2164,47 @@ describe("createAgentChatAdapter", () => {
expect(body.usageLabel).toBe("crm:enrich-record");
});
+ it("sends the run config's action scope with the chat request", async () => {
+ vi.stubGlobal("window", { dispatchEvent: vi.fn() });
+ const fetchSpy = vi.fn().mockResolvedValue(sseResponse([{ type: "done" }]));
+ vi.stubGlobal("fetch", fetchSpy);
+ const adapter = createAgentChatAdapter({
+ apiUrl: "/_agent-native/agent-chat",
+ threadId: "thread-scoped",
+ });
+
+ const results = await drain(
+ adapter.run({
+ messages: [
+ {
+ role: "user",
+ content: [{ type: "text", text: "Draft a reply" }],
+ },
+ ],
+ abortSignal: new AbortController().signal,
+ runConfig: {
+ custom: {
+ actionScope: {
+ kind: "content-comment-ai",
+ requestId: "request-1",
+ },
+ },
+ },
+ } as any),
+ );
+
+ expect(JSON.parse(fetchSpy.mock.calls[0][1].body).actionScope).toEqual({
+ kind: "content-comment-ai",
+ requestId: "request-1",
+ });
+ expect(results.at(-1)?.metadata?.custom).toMatchObject({
+ actionScope: {
+ kind: "content-comment-ai",
+ requestId: "request-1",
+ },
+ });
+ });
+
it("keeps recovery prompts from replacing the original user request", async () => {
vi.stubGlobal("window", { dispatchEvent: vi.fn() });
vi.stubGlobal(
diff --git a/packages/core/src/client/agent-chat-adapter.ts b/packages/core/src/client/agent-chat-adapter.ts
index e20fba778a1..f65dbb8b4bb 100644
--- a/packages/core/src/client/agent-chat-adapter.ts
+++ b/packages/core/src/client/agent-chat-adapter.ts
@@ -8,7 +8,9 @@ import {
LLM_MISSING_CREDENTIALS_ERROR_CODE,
LLM_MISSING_CREDENTIALS_MESSAGE,
} from "../agent/engine/credential-errors.js";
-import type {
+import {
+ normalizeAgentActionScope,
+ type AgentActionScope,
AgentChatStructuredContentPart,
AgentChatStructuredMessage,
} from "../agent/types.js";
@@ -1751,7 +1753,7 @@ function shouldCaptureRecoveryHttpStatus(status: number): boolean {
return status < 500 || status >= 600;
}
-function generateTurnId(): string {
+export function generateAgentChatTurnId(): string {
if (
typeof crypto !== "undefined" &&
typeof crypto.randomUUID === "function"
@@ -2188,6 +2190,18 @@ export function createAgentChatAdapter(
typeof runConfig.custom === "object" &&
(runConfig.custom as { trackInRunsTray?: unknown }).trackInRunsTray ===
true;
+ const actionScope: AgentActionScope | undefined = (() => {
+ if (
+ !runConfig?.custom ||
+ typeof runConfig.custom !== "object" ||
+ !("actionScope" in runConfig.custom)
+ ) {
+ return undefined;
+ }
+ return normalizeAgentActionScope(
+ (runConfig.custom as { actionScope?: unknown }).actionScope,
+ );
+ })();
// Names what the turn is for (`sendToAgentChat({ usageLabel })`). Rides
// the run config so a queued send keeps its label when it finally flushes,
// and every auto-continuation of the turn re-sends the same one.
@@ -2260,7 +2274,7 @@ export function createAgentChatAdapter(
: undefined;
return typeof raw === "string" && raw.trim() ? raw.trim() : undefined;
})();
- const turnId = requestedTurnId ?? generateTurnId();
+ const turnId = requestedTurnId ?? generateAgentChatTurnId();
let streamTransportFallbackUsed = false;
const withRequestModeMetadata = (
@@ -2279,6 +2293,7 @@ export function createAgentChatAdapter(
...custom,
turnId,
...(requestMode ? { requestMode } : {}),
+ ...(actionScope ? { actionScope } : {}),
},
},
};
@@ -4107,6 +4122,7 @@ export function createAgentChatAdapter(
turnId,
...(trackInRunsTray ? { trackInRunsTray: true } : {}),
...(usageLabel ? { usageLabel } : {}),
+ ...(actionScope ? { actionScope } : {}),
...(threadId ? { threadId } : {}),
...(unstable_parentId !== undefined
? { parentId: unstable_parentId }
diff --git a/packages/core/src/client/agent-chat.spec.ts b/packages/core/src/client/agent-chat.spec.ts
index 53dab7ed847..8519b335745 100644
--- a/packages/core/src/client/agent-chat.spec.ts
+++ b/packages/core/src/client/agent-chat.spec.ts
@@ -170,6 +170,43 @@ describe("sendToAgentChat", () => {
expect(parsed?.usageLabel).toBe("crm:enrich");
});
+ it("carries a bounded action scope through the postMessage payload", () => {
+ sendToAgentChat({
+ message: "Draft a reply",
+ actionScope: { kind: "content-comment-ai", requestId: "request-1" },
+ });
+ const payload = parentPostMessageSpy.mock.calls[0][0];
+ const parsed = parseSubmitChatMessage({ data: payload } as MessageEvent);
+
+ expect(parsed?.actionScope).toEqual({
+ kind: "content-comment-ai",
+ requestId: "request-1",
+ });
+ });
+
+ it("rejects malformed and oversized action scopes", () => {
+ expect(() =>
+ sendToAgentChat({
+ message: "Draft a reply",
+ actionScope: { value: Number.NaN },
+ }),
+ ).toThrow("actionScope must contain only JSON values");
+ expect(() =>
+ sendToAgentChat({
+ message: "Draft a reply",
+ actionScope: { value: "x".repeat(9_000) },
+ }),
+ ).toThrow("actionScope must be at most 8192 bytes");
+ expect(
+ parseSubmitChatMessage({
+ data: {
+ type: "agentNative.submitChat",
+ data: { message: "Draft a reply", actionScope: [] },
+ },
+ } as MessageEvent),
+ ).toBeNull();
+ });
+
it("drops a blank usageLabel instead of forwarding an empty label", () => {
const parsed = parseSubmitChatMessage({
data: {
diff --git a/packages/core/src/client/agent-chat.ts b/packages/core/src/client/agent-chat.ts
index 77646ad82cb..6ab5296ba9a 100644
--- a/packages/core/src/client/agent-chat.ts
+++ b/packages/core/src/client/agent-chat.ts
@@ -7,7 +7,13 @@
* stay inside the embedded app so its own AgentSidebar can receive them.
*/
-import type { AgentChatAttachment, MentionItemMedia } from "../agent/types.js";
+import {
+ normalizeAgentActionScope,
+ tryNormalizeAgentActionScope,
+ type AgentActionScope,
+ type AgentChatAttachment,
+ type MentionItemMedia,
+} from "../agent/types.js";
import type { ReasoningEffort } from "../shared/reasoning-effort.js";
import { trackEvent } from "./analytics.js";
import { agentNativePath } from "./api-path.js";
@@ -38,6 +44,8 @@ export interface AgentChatMessage {
message: string;
/** Hidden context appended to the message (not shown in chat UI) */
context?: string;
+ /** App-defined scope requested for the actions exposed to this turn. */
+ actionScope?: AgentActionScope;
/** true = auto-submit, false = prefill only, omit = use project setting */
submit?: boolean;
/** Optional project slug for structured context */
@@ -960,6 +968,7 @@ export interface ParsedSubmitChat {
/** Visible prompt text (non-empty). */
message: string;
context?: string;
+ actionScope?: AgentActionScope;
/** Submit (true) or prefill only (false); defaults to true. */
submit: boolean;
openSidebar?: boolean;
@@ -1049,9 +1058,18 @@ export function parseSubmitChatMessage(
);
const images =
imageSources.length > 0 ? [...new Set(imageSources)] : undefined;
+ const hasActionScope = Object.prototype.hasOwnProperty.call(
+ raw,
+ "actionScope",
+ );
+ const actionScope = hasActionScope
+ ? tryNormalizeAgentActionScope(raw.actionScope)
+ : undefined;
+ if (hasActionScope && !actionScope) return null;
return {
message,
context: typeof raw.context === "string" ? raw.context : undefined,
+ ...(actionScope ? { actionScope } : {}),
submit: raw.submit !== false,
openSidebar:
typeof raw.openSidebar === "boolean" ? raw.openSidebar : undefined,
@@ -1109,6 +1127,10 @@ function readStoredAgentChatRequestMode(): AgentChatRequestMode | undefined {
*/
export function sendToAgentChat(opts: AgentChatMessage): string {
const tabId = opts.tabId ?? generateTabId();
+ const actionScope =
+ opts.actionScope === undefined
+ ? undefined
+ : normalizeAgentActionScope(opts.actionScope);
const isCodeRequest = opts.type === "code" || opts.requiresCode === true;
const localChatTarget = opts.chatTarget === "local";
const requestMode =
@@ -1139,6 +1161,7 @@ export function sendToAgentChat(opts: AgentChatMessage): string {
type: AGENT_CHAT_MESSAGE_TYPE,
data: {
...opts,
+ ...(actionScope ? { actionScope } : {}),
tabId,
submitMessageId,
...(requestMode ? { mode: requestMode, requestMode } : {}),
@@ -1154,7 +1177,7 @@ export function sendToAgentChat(opts: AgentChatMessage): string {
// label. Use the normal wrapper transport when either needs to reach the
// chat thread — a label silently downgraded to `chat` is exactly the run
// the caller named it to be able to find.
- if (opts.attachments?.length || opts.usageLabel) {
+ if (opts.attachments?.length || opts.usageLabel || actionScope) {
window.parent.postMessage(
payload,
getFramePostMessageTargetOrigin() || "*",
diff --git a/packages/core/src/review/suggestions/actions.replay.spec.ts b/packages/core/src/review/suggestions/actions.replay.spec.ts
new file mode 100644
index 00000000000..307cf8ba67f
--- /dev/null
+++ b/packages/core/src/review/suggestions/actions.replay.spec.ts
@@ -0,0 +1,238 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { ResourceSuggestion } from "./types.js";
+
+const transaction = { execute: vi.fn() };
+const validateProposal = vi.fn();
+const insertSuggestion = vi.fn();
+let prior: ResourceSuggestion | null;
+let requestJson: string | null;
+
+vi.mock("../../db/client.js", () => ({
+ getDialect: () => "sqlite",
+ getDbExec: () => ({
+ transaction: async (run: (tx: typeof transaction) => Promise) =>
+ run(transaction),
+ }),
+ intType: () => "INTEGER",
+ isPostgres: () => false,
+}));
+vi.mock("../notifications.js", () => ({ notifyReviewComment: vi.fn() }));
+vi.mock("../store.js", () => ({
+ ensureReviewTables: vi.fn(),
+ insertReviewCommentWithClient: vi.fn(),
+ resolveReviewThreadWithClient: vi.fn(),
+}));
+vi.mock("./store.js", () => ({
+ ensureSuggestionTables: vi.fn(),
+ getSuggestion: vi.fn(),
+ getSuggestionByCreationKey: vi.fn(async () =>
+ prior ? { suggestion: prior, requestJson } : null,
+ ),
+ insertSuggestion,
+ listSuggestions: vi.fn(),
+ recordDecision: vi.fn(),
+ getDecision: vi.fn(),
+ recordSuggestionCreation: vi.fn(),
+ replaceSuggestionStatus: vi.fn(),
+ updateSuggestionStatus: vi.fn(),
+}));
+
+const { createResourceSuggestion } = await import("./actions.js");
+const { __resetReviewableResourcesForTests, registerReviewableResource } =
+ await import("../registry.js");
+const { __resetSuggestionAdaptersForTests, registerSuggestionAdapter } =
+ await import("./registry.js");
+
+const operations = [
+ {
+ ordinal: 0,
+ kind: "replace_text",
+ after: { text: "new", marks: ["bold"] },
+ schemaVersion: 1,
+ },
+];
+const args = {
+ resourceType: "doc",
+ resourceId: "doc-1",
+ adapterKind: "test.adapter",
+ baseRevision: "revision-1",
+ summary: "Replace text",
+ idempotencyKey: "creation-1",
+ metadata: { source: "agent", nested: { z: 1, a: 2 } },
+ operations,
+};
+
+function stableJson(value: unknown): string {
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
+ if (Array.isArray(value)) {
+ return `[${value.map((item) => stableJson(item)).join(",")}]`;
+ }
+ const object = value as Record;
+ return `{${Object.keys(object)
+ .sort()
+ .map((key) => `${JSON.stringify(key)}:${stableJson(object[key])}`)
+ .join(",")}}`;
+}
+
+function replayRequestJson(): string {
+ return stableJson({
+ resourceType: args.resourceType,
+ resourceId: args.resourceId,
+ adapterKind: args.adapterKind,
+ baseRevision: args.baseRevision,
+ summary: args.summary,
+ metadata: args.metadata,
+ authorEmail: "agent@example.com",
+ actorKind: "agent",
+ operations: [
+ {
+ ordinal: 0,
+ kind: "replace_text",
+ targetId: null,
+ before: null,
+ after: { text: "new", marks: ["bold"] },
+ anchor: null,
+ dependencies: null,
+ schemaVersion: 1,
+ },
+ ],
+ });
+}
+
+function makePrior(): ResourceSuggestion {
+ return {
+ id: "suggestion-1",
+ resourceType: args.resourceType,
+ resourceId: args.resourceId,
+ adapterKind: args.adapterKind,
+ adapterVersion: 1,
+ threadId: "thread-1",
+ authorEmail: "agent@example.com",
+ actorKind: "agent",
+ baseRevision: args.baseRevision,
+ status: "accepted",
+ summary: args.summary,
+ ownerEmail: "owner@example.com",
+ orgId: null,
+ visibility: "private",
+ createdAt: "earlier",
+ updatedAt: "later",
+ metadata: { nested: { a: 2, z: 1 }, source: "agent" },
+ operations: [
+ {
+ id: "stored-operation-1",
+ ordinal: 0,
+ kind: "replace_text",
+ targetId: null,
+ before: null,
+ after: { marks: ["bold"], text: "new" },
+ anchor: null,
+ dependencies: null,
+ schemaVersion: 1,
+ },
+ ],
+ };
+}
+
+describe("suggestion creation replay", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ prior = makePrior();
+ requestJson = replayRequestJson();
+ validateProposal.mockRejectedValue(
+ new Error("base revision is no longer current"),
+ );
+ __resetReviewableResourcesForTests();
+ __resetSuggestionAdaptersForTests();
+ registerReviewableResource({
+ type: "doc",
+ resolveAccess: () => ({
+ role: "commenter",
+ ownerEmail: "owner@example.com",
+ visibility: "private",
+ }),
+ });
+ registerSuggestionAdapter({
+ kind: "test.adapter",
+ version: 1,
+ validateProposal,
+ apply: vi.fn(),
+ });
+ });
+
+ it("returns the original accepted suggestion before mutable validation", async () => {
+ await expect(
+ createResourceSuggestion.run(args, {
+ caller: "tool",
+ userEmail: "agent@example.com",
+ }),
+ ).resolves.toEqual(prior);
+ expect(validateProposal).not.toHaveBeenCalled();
+ expect(insertSuggestion).not.toHaveBeenCalled();
+ });
+
+ it("replays from the pre-validation payload when the adapter transformed operations", async () => {
+ prior = {
+ ...makePrior(),
+ operations: [
+ {
+ ordinal: 0,
+ kind: "replace_text",
+ after: { normalizedText: "new" },
+ schemaVersion: 2,
+ },
+ ],
+ };
+ await expect(
+ createResourceSuggestion.run(args, {
+ caller: "tool",
+ userEmail: "agent@example.com",
+ }),
+ ).resolves.toEqual(prior);
+ expect(validateProposal).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ["resource", { resourceId: "doc-2" }],
+ ["adapter", { adapterKind: "other.adapter" }],
+ ["base revision", { baseRevision: "revision-2" }],
+ ["summary", { summary: "Different summary" }],
+ ["metadata", { metadata: { source: "human" } }],
+ ["operations", { operations: [{ ...operations[0], kind: "delete" }] }],
+ ])("rejects replay with changed %s", async (_field, changed) => {
+ await expect(
+ createResourceSuggestion.run(
+ { ...args, ...changed },
+ { caller: "tool", userEmail: "agent@example.com" },
+ ),
+ ).rejects.toThrow(
+ "Idempotency key was already used for a different suggestion",
+ );
+ expect(validateProposal).not.toHaveBeenCalled();
+ });
+
+ it("rejects replay from a different author", async () => {
+ await expect(
+ createResourceSuggestion.run(args, {
+ caller: "tool",
+ userEmail: "other-agent@example.com",
+ }),
+ ).rejects.toThrow(
+ "Idempotency key was already used for a different suggestion",
+ );
+ expect(validateProposal).not.toHaveBeenCalled();
+ });
+
+ it("rejects replay from a different actor kind", async () => {
+ await expect(
+ createResourceSuggestion.run(args, {
+ caller: "frontend",
+ userEmail: "agent@example.com",
+ }),
+ ).rejects.toThrow(
+ "Idempotency key was already used for a different suggestion",
+ );
+ expect(validateProposal).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/core/src/review/suggestions/actions.ts b/packages/core/src/review/suggestions/actions.ts
index 330d15f6f0a..598fd7b28f0 100644
--- a/packages/core/src/review/suggestions/actions.ts
+++ b/packages/core/src/review/suggestions/actions.ts
@@ -23,6 +23,7 @@ import {
updateSuggestionStatus,
} from "./store.js";
import type { ResourceSuggestion } from "./types.js";
+import type { SuggestionOperation } from "./types.js";
const base = { resourceType: z.string().min(1), resourceId: z.string().min(1) };
const operation = z.object({
@@ -36,6 +37,87 @@ const operation = z.object({
schemaVersion: z.number().int().positive(),
});
+function stableJson(value: unknown): string {
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
+ if (Array.isArray(value)) {
+ return `[${value.map((item) => stableJson(item)).join(",")}]`;
+ }
+ const object = value as Record;
+ return `{${Object.keys(object)
+ .sort()
+ .map((key) => `${JSON.stringify(key)}:${stableJson(object[key])}`)
+ .join(",")}}`;
+}
+
+function canonicalJson(value: unknown): string {
+ const encoded = JSON.stringify(value);
+ if (encoded === undefined) {
+ throw new TypeError("Suggestion payload must contain only JSON values");
+ }
+ return stableJson(JSON.parse(encoded));
+}
+
+function normalizeOperations(
+ operations: readonly SuggestionOperation[],
+): SuggestionOperation[] {
+ return operations.map((item) => ({
+ ordinal: item.ordinal,
+ kind: item.kind,
+ targetId: item.targetId ?? null,
+ before: item.before ?? null,
+ after: item.after ?? null,
+ anchor: item.anchor ?? null,
+ dependencies: item.dependencies ?? null,
+ schemaVersion: item.schemaVersion,
+ }));
+}
+
+function isMatchingCreationReplay(
+ prior: ResourceSuggestion,
+ input: {
+ resourceType: string;
+ resourceId: string;
+ adapterKind: string;
+ baseRevision: string;
+ summary: string;
+ metadata?: Record;
+ operations: SuggestionOperation[];
+ },
+ authorEmail: string | null,
+ actorKind: ResourceSuggestion["actorKind"],
+): boolean {
+ return (
+ prior.resourceType === input.resourceType &&
+ prior.resourceId === input.resourceId &&
+ prior.adapterKind === input.adapterKind &&
+ prior.baseRevision === input.baseRevision &&
+ prior.summary === input.summary &&
+ prior.authorEmail === authorEmail &&
+ prior.actorKind === actorKind &&
+ canonicalJson(prior.metadata) === canonicalJson(input.metadata ?? null) &&
+ canonicalJson(normalizeOperations(prior.operations)) ===
+ canonicalJson(normalizeOperations(input.operations))
+ );
+}
+
+function creationRequestJson(
+ input: Parameters[1],
+ authorEmail: string | null,
+ actorKind: ResourceSuggestion["actorKind"],
+): string {
+ return canonicalJson({
+ resourceType: input.resourceType,
+ resourceId: input.resourceId,
+ adapterKind: input.adapterKind,
+ baseRevision: input.baseRevision,
+ summary: input.summary,
+ metadata: input.metadata ?? null,
+ authorEmail,
+ actorKind,
+ operations: normalizeOperations(input.operations),
+ });
+}
+
export const createResourceSuggestion = defineAction({
description:
"Create a typed pending suggestion without changing the canonical resource.",
@@ -63,18 +145,14 @@ export const createResourceSuggestion = defineAction({
ctx as any,
"commenter",
);
- const adapter = getSuggestionAdapter(args.adapterKind);
- if (!adapter) throw new Error("Suggestion adapter not registered");
- const adapterContext = { ...(ctx as any), suggestionAccess: access };
- const operations =
- (await adapter.validateProposal({ ...args, ctx: adapterContext })) ??
- args.operations;
const actorKind =
(ctx as any)?.caller === "agent" || (ctx as any)?.caller === "tool"
? "agent"
: (ctx as any)?.userEmail
? "human"
: "system";
+ const authorEmail = (ctx as any)?.userEmail ?? null;
+ const requestJson = creationRequestJson(args, authorEmail, actorKind);
const db = getDbExec();
await ensureSuggestionTables();
await ensureReviewTables();
@@ -83,20 +161,33 @@ export const createResourceSuggestion = defineAction({
"Suggestion creation requires an atomic database transaction",
);
const result = await db.transaction(async (tx) => {
- const prior = await getSuggestionByCreationKey(tx, args.idempotencyKey);
- if (prior) {
+ const creation = await getSuggestionByCreationKey(
+ tx,
+ args.idempotencyKey,
+ );
+ if (creation) {
if (
- prior.resourceType !== args.resourceType ||
- prior.resourceId !== args.resourceId ||
- prior.adapterKind !== args.adapterKind ||
- prior.baseRevision !== args.baseRevision
+ creation.requestJson !== null
+ ? creation.requestJson !== requestJson
+ : !isMatchingCreationReplay(
+ creation.suggestion,
+ args,
+ authorEmail,
+ actorKind,
+ )
) {
throw new Error(
"Idempotency key was already used for a different suggestion",
);
}
- return { suggestion: prior, threadComment: null };
+ return { suggestion: creation.suggestion, threadComment: null };
}
+ const adapter = getSuggestionAdapter(args.adapterKind);
+ if (!adapter) throw new Error("Suggestion adapter not registered");
+ const adapterContext = { ...(ctx as any), suggestionAccess: access };
+ const operations =
+ (await adapter.validateProposal({ ...args, ctx: adapterContext })) ??
+ args.operations;
const created = await insertSuggestion(
{
resourceType: args.resourceType,
@@ -104,7 +195,7 @@ export const createResourceSuggestion = defineAction({
adapterKind: adapter.kind,
adapterVersion: adapter.version,
threadId: `suggestion-thread-${globalThis.crypto.randomUUID()}`,
- authorEmail: (ctx as any)?.userEmail ?? null,
+ authorEmail,
actorKind,
baseRevision: args.baseRevision,
status: "pending",
@@ -117,7 +208,12 @@ export const createResourceSuggestion = defineAction({
},
tx,
);
- await recordSuggestionCreation(tx, args.idempotencyKey, created.id);
+ await recordSuggestionCreation(
+ tx,
+ args.idempotencyKey,
+ created.id,
+ requestJson,
+ );
const threadComment = await insertReviewCommentWithClient(
{
resourceType: created.resourceType,
diff --git a/packages/core/src/review/suggestions/store.spec.ts b/packages/core/src/review/suggestions/store.spec.ts
index 8cb2f5e3eb3..2d91b7d21ba 100644
--- a/packages/core/src/review/suggestions/store.spec.ts
+++ b/packages/core/src/review/suggestions/store.spec.ts
@@ -43,6 +43,8 @@ const {
ensureSuggestionTables,
insertSuggestion,
getSuggestion,
+ getSuggestionByCreationKey,
+ recordSuggestionCreation,
recordDecision,
__resetSuggestionTablesForTests,
} = await import("./store.js");
@@ -138,4 +140,25 @@ describe("suggestion store", () => {
}),
).rejects.toThrow("different decision");
});
+
+ it("retains the pre-validation request for keyed creation replay", async () => {
+ const suggestion = await insertSuggestion(input);
+ await recordSuggestionCreation(
+ rawClient,
+ "creation-key-1",
+ suggestion.id,
+ '{"operations":[{"kind":"replace_text"}]}',
+ );
+ const creation = await getSuggestionByCreationKey(
+ rawClient,
+ "creation-key-1",
+ );
+ expect(creation?.suggestion.id).toBe(suggestion.id);
+ expect(creation?.suggestion.operations[0]).toMatchObject(
+ suggestion.operations[0],
+ );
+ expect(creation?.requestJson).toBe(
+ '{"operations":[{"kind":"replace_text"}]}',
+ );
+ });
});
diff --git a/packages/core/src/review/suggestions/store.ts b/packages/core/src/review/suggestions/store.ts
index 955d8ae27f4..32cb1fc6c6e 100644
--- a/packages/core/src/review/suggestions/store.ts
+++ b/packages/core/src/review/suggestions/store.ts
@@ -1,5 +1,5 @@
import { getDbExec, type DbExec } from "../../db/client.js";
-import { ensureTableExists } from "../../db/ddl-guard.js";
+import { ensureColumnExists, ensureTableExists } from "../../db/ddl-guard.js";
import type { Visibility } from "../../sharing/schema.js";
import type {
ResourceSuggestion,
@@ -27,12 +27,17 @@ export async function ensureSuggestionTables(
`CREATE TABLE IF NOT EXISTS agent_review_suggestions (id TEXT PRIMARY KEY, resource_type TEXT NOT NULL, resource_id TEXT NOT NULL, adapter_kind TEXT NOT NULL, adapter_version INTEGER NOT NULL, thread_id TEXT NOT NULL, author_email TEXT, actor_kind TEXT NOT NULL, base_revision TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', summary TEXT NOT NULL, owner_email TEXT, org_id TEXT, visibility TEXT NOT NULL DEFAULT 'private', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, metadata_json TEXT)`,
`CREATE TABLE IF NOT EXISTS agent_review_suggestion_operations (id TEXT PRIMARY KEY, suggestion_id TEXT NOT NULL, ordinal INTEGER NOT NULL, operation_kind TEXT NOT NULL, target_id TEXT, before_json TEXT, after_json TEXT, anchor_json TEXT, dependencies_json TEXT, schema_version INTEGER NOT NULL)`,
`CREATE TABLE IF NOT EXISTS agent_review_suggestion_decisions (id TEXT PRIMARY KEY, suggestion_id TEXT NOT NULL, idempotency_key TEXT NOT NULL UNIQUE, reviewer TEXT, decision TEXT NOT NULL, observed_base TEXT, outcome TEXT NOT NULL, detail TEXT, created_at TEXT NOT NULL)`,
- `CREATE TABLE IF NOT EXISTS agent_review_suggestion_creations (idempotency_key TEXT PRIMARY KEY, suggestion_id TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL)`,
+ `CREATE TABLE IF NOT EXISTS agent_review_suggestion_creations (idempotency_key TEXT PRIMARY KEY, suggestion_id TEXT NOT NULL UNIQUE, request_json TEXT, created_at TEXT NOT NULL)`,
];
for (const sql of ddl) {
const name = sql.match(/agent_review_[a-z_]+/)![0];
await ensureTableExists(name, sql);
}
+ await ensureColumnExists(
+ "agent_review_suggestion_creations",
+ "request_json",
+ "ALTER TABLE agent_review_suggestion_creations ADD COLUMN IF NOT EXISTS request_json TEXT",
+ );
await client.execute(
"CREATE INDEX IF NOT EXISTS idx_review_suggestions_resource ON agent_review_suggestions (resource_type, resource_id, created_at)",
);
@@ -46,24 +51,36 @@ export async function ensureSuggestionTables(
export async function getSuggestionByCreationKey(
client: DbExec,
idempotencyKey: string,
-): Promise {
+): Promise<{
+ suggestion: ResourceSuggestion;
+ requestJson: string | null;
+} | null> {
const row = (
await client.execute({
- sql: "SELECT suggestion_id FROM agent_review_suggestion_creations WHERE idempotency_key = ?",
+ sql: "SELECT suggestion_id, request_json FROM agent_review_suggestion_creations WHERE idempotency_key = ?",
args: [idempotencyKey],
})
).rows[0];
- return row ? getSuggestion(String(row.suggestion_id), client) : null;
+ if (!row) return null;
+ const suggestion = await getSuggestion(String(row.suggestion_id), client);
+ if (!suggestion) {
+ throw new Error("Suggestion creation key references a missing suggestion");
+ }
+ return {
+ suggestion,
+ requestJson: row.request_json == null ? null : String(row.request_json),
+ };
}
export async function recordSuggestionCreation(
client: DbExec,
idempotencyKey: string,
suggestionId: string,
+ requestJson: string,
): Promise {
await client.execute({
- sql: "INSERT INTO agent_review_suggestion_creations (idempotency_key,suggestion_id,created_at) VALUES (?,?,?)",
- args: [idempotencyKey, suggestionId, new Date().toISOString()],
+ sql: "INSERT INTO agent_review_suggestion_creations (idempotency_key,suggestion_id,request_json,created_at) VALUES (?,?,?,?)",
+ args: [idempotencyKey, suggestionId, requestJson, new Date().toISOString()],
});
}
diff --git a/packages/core/src/server/agent-chat-plugin.surface.spec.ts b/packages/core/src/server/agent-chat-plugin.surface.spec.ts
index a42acf268bf..4eafe05e1b2 100644
--- a/packages/core/src/server/agent-chat-plugin.surface.spec.ts
+++ b/packages/core/src/server/agent-chat-plugin.surface.spec.ts
@@ -272,7 +272,7 @@ describe("request-scoped action surface", () => {
/const localDevActionNames = new Set\(Object\.keys\(devScriptRegistry\)\);/,
);
expect(source).toMatch(
- /availableActionNames: appActionNames,[\s\S]*?normalizeAgentActionSurfaceResolution\([\s\S]*?if \(normalizedSurface\.mode === "default"\) return surface;[\s\S]*?allowedActionNames: \[[\s\S]*?\.\.\.normalizedSurface\.allowedActionNames,[\s\S]*?\.\.\.localActionNames,/s,
+ /availableActionNames: appActionNames,[\s\S]*?normalizeAgentActionSurfaceResolution\([\s\S]*?if \(normalizedSurface\.mode === "default"\) return surface;[\s\S]*?if \(normalizedSurface\.actionScope\)[\s\S]*?allowedActionNames: normalizedSurface\.allowedActionNames,[\s\S]*?actionScope: normalizedSurface\.actionScope,[\s\S]*?allowedActionNames: \[[\s\S]*?\.\.\.normalizedSurface\.allowedActionNames,[\s\S]*?\.\.\.localActionNames,/s,
);
expect(devSource).toMatch(
/unauthorizedActionFromBash\([\s\S]*?getRequestRunContext\(\)\?\.allowedActionNames/s,
diff --git a/packages/core/src/server/agent-chat-plugin.ts b/packages/core/src/server/agent-chat-plugin.ts
index 237a4032bf6..4d0953e2db7 100644
--- a/packages/core/src/server/agent-chat-plugin.ts
+++ b/packages/core/src/server/agent-chat-plugin.ts
@@ -4008,6 +4008,12 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su
const normalizedSurface =
normalizeAgentActionSurfaceResolution(surface);
if (normalizedSurface.mode === "default") return surface;
+ if (normalizedSurface.actionScope) {
+ return {
+ allowedActionNames: normalizedSurface.allowedActionNames,
+ actionScope: normalizedSurface.actionScope,
+ };
+ }
const localActionNames = details.availableActionNames.filter(
(name) => localDevActionNames.has(name),
);
diff --git a/packages/core/src/server/index.ts b/packages/core/src/server/index.ts
index 252bc97be38..0e9539dbe25 100644
--- a/packages/core/src/server/index.ts
+++ b/packages/core/src/server/index.ts
@@ -146,6 +146,7 @@ export {
type AgentLoopToolCallSummary,
type AgentLoopToolResultSummary,
} from "../agent/index.js";
+export type { AgentActionScope } from "../agent/types.js";
export {
actionsToEngineTools,
executeAgentToolCall,
@@ -157,6 +158,8 @@ export {
type ExecuteAgentToolCallOptions,
type ResolvedOwnerApiKey,
} from "../agent/production-agent.js";
+export { getRunStatus, getRunTurnRef } from "../agent/run-store.js";
+export { getActiveRunForThreadAsync } from "../agent/run-manager.js";
export {
mountRealtimeVoiceRoutes,
realtimeVoiceSafetyIdentifier,
diff --git a/packages/core/src/server/request-context.ts b/packages/core/src/server/request-context.ts
index 8972d005ceb..6a103fa4467 100644
--- a/packages/core/src/server/request-context.ts
+++ b/packages/core/src/server/request-context.ts
@@ -16,6 +16,7 @@
* continue to work.
*/
+import type { AgentActionScope } from "../agent/types.js";
import type { SignupAttributionContext } from "./attribution.js";
type AsyncLocalStorageLike = {
@@ -120,6 +121,8 @@ export interface RequestRunContext {
model?: string;
/** Request-authorized action names exposed to this agent run. */
allowedActionNames?: readonly string[];
+ /** Server-resolved app data used by the request-authorized actions. */
+ actionScope?: Readonly;
/** Hosted tools-only harness selected for this agent run. */
hostedHarnessRuntime?: "claude-code" | "codex" | "pi" | "opencode";
/**
@@ -333,7 +336,10 @@ export function runWithRequestContext(
inheritedSyntheticTraffic !== undefined
? { ...ctx, isSyntheticTraffic: inheritedSyntheticTraffic }
: ctx;
- if (context.run?.allowedActionNames !== undefined) {
+ if (
+ context.run?.allowedActionNames !== undefined ||
+ context.run?.actionScope !== undefined
+ ) {
assertRequestActionSurfaceIsolation();
}
return als.run(context, () => {
diff --git a/templates/content/actions/add-comment.test.ts b/templates/content/actions/add-comment.test.ts
index 84ee7835286..e48b8332191 100644
--- a/templates/content/actions/add-comment.test.ts
+++ b/templates/content/actions/add-comment.test.ts
@@ -5,11 +5,14 @@ type CommentRow = {
documentId: string;
threadId: string;
parentId: string | null;
+ [key: string]: unknown;
};
const state = vi.hoisted(() => ({
rows: [] as CommentRow[],
inserted: [] as Record[],
+ agent: true,
+ lock: undefined as (() => Promise) | undefined,
}));
const mockAssertAccess = vi.hoisted(() =>
vi.fn(async () => ({
@@ -21,7 +24,7 @@ vi.mock("@agent-native/core/sharing", () => ({
assertAccess: (...args: unknown[]) => mockAssertAccess(...args),
}));
vi.mock("@agent-native/core/server", () => ({
- getRequestRunContext: () => ({ caller: "mcp" }),
+ getRequestRunContext: () => (state.agent ? { caller: "mcp" } : undefined),
getRequestUserEmail: () => "author@example.com",
getRequestUserName: () => "Authenticated Profile Name",
}));
@@ -44,6 +47,7 @@ function matches(row: CommentRow, condition: any): boolean {
vi.mock("../server/db/index.js", () => {
const column = (name: string) => `documentComments.${name}`;
const schema = {
+ documents: { id: "documents.id", ownerEmail: "documents.ownerEmail" },
documentComments: {
id: column("id"),
documentId: column("documentId"),
@@ -54,29 +58,45 @@ vi.mock("../server/db/index.js", () => {
select: () => ({
from: () => ({
where: (condition: unknown) => ({
+ for: async () => {
+ await state.lock?.();
+ return [{ id: "doc-1" }];
+ },
limit: async () =>
state.rows
.filter((row) => matches(row, condition))
- .map((row) => ({ threadId: row.threadId })),
+ .map((row) => ({ ...row })),
}),
}),
}),
insert: () => ({
- values: async (value: Record) => {
- state.inserted.push(value);
- },
+ values: (value: Record) => ({
+ onConflictDoNothing: () => ({
+ returning: async () => {
+ if (state.rows.some((row) => row.id === value.id)) return [];
+ state.inserted.push(value);
+ state.rows.push(value as CommentRow);
+ return [{ id: value.id }];
+ },
+ }),
+ }),
}),
};
- return { getDb: () => db, schema };
+ return {
+ getDb: () => ({ ...db, transaction: (run: any) => run(db) }),
+ schema,
+ };
});
-import action from "./add-comment";
+import action, { addCommentWithGuard } from "./add-comment";
const run = (args: Record) => (action as any).run(args);
beforeEach(() => {
vi.clearAllMocks();
state.inserted = [];
+ state.agent = true;
+ state.lock = undefined;
state.rows = [
{ id: "root-1", documentId: "doc-1", threadId: "root-1", parentId: null },
{ id: "root-2", documentId: "doc-2", threadId: "root-2", parentId: null },
@@ -101,6 +121,31 @@ describe("add-comment reply boundary", () => {
});
});
+ it("does not insert a reply while final resolution holds the document lock", async () => {
+ let release!: () => void;
+ let entered!: () => void;
+ const waiting = new Promise((resolve) => {
+ entered = resolve;
+ });
+ state.lock = () => {
+ entered();
+ return new Promise((resolve) => {
+ release = resolve;
+ });
+ };
+ const pending = run({
+ documentId: "doc-1",
+ content: "Concurrent reply",
+ threadId: "root-1",
+ parentId: "root-1",
+ });
+ await waiting;
+ expect(state.inserted).toHaveLength(0);
+ release();
+ await pending;
+ expect(state.inserted).toHaveLength(1);
+ });
+
it("derives authorship from the authenticated caller", async () => {
await run({
documentId: "doc-1",
@@ -110,10 +155,80 @@ describe("add-comment reply boundary", () => {
expect(state.inserted[0]).toMatchObject({
authorEmail: "author@example.com",
- authorName: "Author",
+ authorName: "AI Agent",
+ actorKind: "agent",
});
});
+ it("preserves human identity outside trusted agent execution", async () => {
+ state.agent = false;
+ await run({
+ documentId: "doc-1",
+ content: "Human comment",
+ actorKind: "agent",
+ });
+ expect(state.inserted[0]).toMatchObject({
+ authorName: "Authenticated Profile Name",
+ actorKind: "human",
+ });
+ });
+
+ it("reconciles an identical retry and rejects changed content for its key", async () => {
+ const args = {
+ documentId: "doc-1",
+ threadId: "root-1",
+ parentId: "root-1",
+ content: "One answer",
+ idempotencyKey: "request-1",
+ };
+ const first = await run(args);
+ const retry = await run(args);
+ expect(retry.id).toBe(first.id);
+ expect(state.inserted).toHaveLength(1);
+ await expect(run({ ...args, content: "Different answer" })).rejects.toThrow(
+ "different comment",
+ );
+ expect(state.inserted).toHaveLength(1);
+ });
+
+ it.each([
+ { quotedText: "Different quote" },
+ { anchorPrefix: "Different prefix" },
+ { anchorSuffix: "Different suffix" },
+ { anchorStartOffset: 20 },
+ { mentions: [{ email: "other@example.test", name: "Other" }] },
+ ])("rejects a retry with changed comment context: %o", async (changed) => {
+ const args = {
+ documentId: "doc-1",
+ content: "Same text",
+ idempotencyKey: "context-request",
+ };
+ await run(args);
+ await expect(run({ ...args, ...changed })).rejects.toThrow(
+ "different comment",
+ );
+ expect(state.inserted).toHaveLength(1);
+ });
+
+ it("recovers an already saved reply before checking a now-stale source", async () => {
+ const args = {
+ documentId: "doc-1",
+ threadId: "root-1",
+ parentId: "root-1",
+ content: "Saved answer",
+ idempotencyKey: "saved-request",
+ };
+ await run(args);
+ const guard = vi.fn(async () => {
+ throw new Error("Page changed later");
+ });
+ await expect(
+ addCommentWithGuard(args, undefined, guard),
+ ).resolves.toMatchObject({ duplicate: true });
+ expect(guard).not.toHaveBeenCalled();
+ expect(state.inserted).toHaveLength(1);
+ });
+
it.each([
{ threadId: "root-1" },
{ parentId: "root-1" },
diff --git a/templates/content/actions/add-comment.ts b/templates/content/actions/add-comment.ts
index 605e5a3eb91..a6ae42c9ec3 100644
--- a/templates/content/actions/add-comment.ts
+++ b/templates/content/actions/add-comment.ts
@@ -1,4 +1,6 @@
-import { defineAction } from "@agent-native/core/action";
+import { createHash } from "node:crypto";
+
+import { defineAction, type ActionRunContext } from "@agent-native/core/action";
import {
getRequestRunContext,
getRequestUserEmail,
@@ -48,57 +50,120 @@ function displayNameFromEmail(email: string): string {
return words.join(" ");
}
-export default defineAction({
- description:
- "Add a comment to a document. Comment text supports inline Markdown for emphasis, inline code, links, and line breaks; headings are flattened. To reply, provide both threadId and parentId; omit both to start a thread.",
- deferLoading: false,
- mcpTool: true,
- schema: z.object({
- documentId: z.string().describe("Document ID"),
- content: z.string().min(1).describe("Comment text"),
- threadId: z
- .string()
- .min(1)
- .optional()
- .describe("Thread ID; provide with parentId when replying"),
- parentId: z
- .string()
- .min(1)
- .optional()
- .describe("Parent comment ID; provide with threadId when replying"),
- quotedText: z.string().optional().describe("Quoted text for the thread"),
- anchorPrefix: z
- .string()
- .optional()
- .describe("Text immediately before the quote, for robust anchoring"),
- anchorSuffix: z
- .string()
- .optional()
- .describe("Text immediately after the quote, for robust anchoring"),
- anchorStartOffset: z.coerce
- .number()
- .optional()
- .describe("Character offset of the quote start within the document"),
- mentions: z
- .union([z.string(), z.array(z.unknown())])
- .optional()
- .describe(
- 'JSON-encoded array of {email, name} mentions, e.g. [{"email":"a@x.com","name":"A"}]',
- ),
- }),
- run: async (args) => {
- const documentId = args.documentId;
- const content = args.content;
-
- if (Boolean(args.threadId) !== Boolean(args.parentId)) {
- throw new Error("Replies require both threadId and parentId");
- }
+export function commentIdForIdempotency(
+ email: string,
+ documentId: string,
+ key: string,
+) {
+ return `comment-${createHash("sha256")
+ .update(JSON.stringify([email, documentId, key]))
+ .digest("hex")}`;
+}
+
+const commentSchema = z.object({
+ documentId: z.string().describe("Document ID"),
+ content: z.string().min(1).describe("Comment text"),
+ idempotencyKey: z
+ .string()
+ .min(1)
+ .max(200)
+ .optional()
+ .describe("Stable key for retrying the same comment without duplication"),
+ threadId: z
+ .string()
+ .min(1)
+ .optional()
+ .describe("Thread ID; provide with parentId when replying"),
+ parentId: z
+ .string()
+ .min(1)
+ .optional()
+ .describe("Parent comment ID; provide with threadId when replying"),
+ quotedText: z.string().optional().describe("Quoted text for the thread"),
+ anchorPrefix: z
+ .string()
+ .optional()
+ .describe("Text immediately before the quote, for robust anchoring"),
+ anchorSuffix: z
+ .string()
+ .optional()
+ .describe("Text immediately after the quote, for robust anchoring"),
+ anchorStartOffset: z.coerce
+ .number()
+ .optional()
+ .describe("Character offset of the quote start within the document"),
+ mentions: z
+ .union([z.string(), z.array(z.unknown())])
+ .optional()
+ .describe(
+ 'JSON-encoded array of {email, name} mentions, e.g. [{"email":"a@x.com","name":"A"}]',
+ ),
+});
+
+type CommentTransaction = Parameters<
+ Parameters["transaction"]>[0]
+>[0];
+
+export async function addCommentWithGuard(
+ args: z.infer,
+ ctx?: ActionRunContext,
+ beforeInsert?: (tx: CommentTransaction) => Promise,
+) {
+ const documentId = args.documentId;
+ const content = args.content;
+
+ if (Boolean(args.threadId) !== Boolean(args.parentId)) {
+ throw new Error("Replies require both threadId and parentId");
+ }
- const access = await assertAccess("document", documentId, "commenter");
- const ownerEmail = access.resource.ownerEmail as string;
- const db = getDb();
+ const access = await assertAccess("document", documentId, "commenter");
+ const ownerEmail = access.resource.ownerEmail as string;
+ const db = getDb();
+
+ const email = getRequestUserEmail();
+ if (!email) throw new Error("no authenticated user");
+ const id = args.idempotencyKey
+ ? commentIdForIdempotency(email, documentId, args.idempotencyKey)
+ : Math.random().toString(36).slice(2, 14);
+ const threadId = args.threadId ?? id;
+ const parentId = args.parentId ?? null;
+ const actorKind =
+ getRequestRunContext() ||
+ ctx?.caller === "tool" ||
+ ctx?.caller === "mcp" ||
+ ctx?.caller === "a2a"
+ ? "agent"
+ : "human";
+ const requestName =
+ actorKind === "agent" ? undefined : getRequestUserName()?.trim();
+ let name: string;
+ if (actorKind === "agent") {
+ name = "AI Agent";
+ } else if (requestName) {
+ name = requestName;
+ } else {
+ const derived = displayNameFromEmail(email).trim();
+ name = derived || "AI Agent";
+ }
+
+ const mentions = parseMentions(args.mentions);
+ const mentionsJson = mentions.length > 0 ? JSON.stringify(mentions) : null;
+
+ const created = await db.transaction(async (tx) => {
+ // Resolution locks this same row before checking the thread snapshot.
+ const [document] = await tx
+ .select({ id: schema.documents.id })
+ .from(schema.documents)
+ .where(
+ and(
+ eq(schema.documents.id, documentId),
+ eq(schema.documents.ownerEmail, ownerEmail),
+ ),
+ )
+ .for("update");
+ if (!document) throw new Error("The document is no longer available");
if (args.threadId && args.parentId) {
- const [parent] = await db
+ const [parent] = await tx
.select({ threadId: schema.documentComments.threadId })
.from(schema.documentComments)
.where(
@@ -113,55 +178,80 @@ export default defineAction({
}
}
- const id = Math.random().toString(36).slice(2, 14);
- const threadId = args.threadId ?? id;
- const parentId = args.parentId ?? null;
- const email = getRequestUserEmail();
- if (!email) throw new Error("no authenticated user");
-
- const requestName = getRequestRunContext()
- ? undefined
- : getRequestUserName()?.trim();
- let name: string;
- if (requestName) {
- name = requestName;
- } else {
- const derived = displayNameFromEmail(email).trim();
- name = derived || "AI Agent";
+ const [prior] = await tx
+ .select()
+ .from(schema.documentComments)
+ .where(eq(schema.documentComments.id, id))
+ .limit(1);
+ if (prior) {
+ if (
+ prior.documentId !== documentId ||
+ prior.authorEmail !== email ||
+ prior.threadId !== threadId ||
+ prior.parentId !== parentId ||
+ prior.content !== content ||
+ prior.actorKind !== actorKind ||
+ prior.quotedText !== (args.quotedText ?? null) ||
+ prior.anchorPrefix !== (args.anchorPrefix ?? null) ||
+ prior.anchorSuffix !== (args.anchorSuffix ?? null) ||
+ prior.anchorStartOffset !== (args.anchorStartOffset ?? null) ||
+ prior.mentionsJson !== mentionsJson
+ ) {
+ throw new Error(
+ "Comment idempotency key was already used for a different comment",
+ );
+ }
+ return false;
}
+ await beforeInsert?.(tx);
+ const inserted = await tx
+ .insert(schema.documentComments)
+ .values({
+ id,
+ ownerEmail,
+ documentId,
+ threadId,
+ parentId,
+ content,
+ quotedText: args.quotedText ?? null,
+ anchorPrefix: args.anchorPrefix ?? null,
+ anchorSuffix: args.anchorSuffix ?? null,
+ anchorStartOffset: args.anchorStartOffset ?? null,
+ mentionsJson,
+ authorEmail: email,
+ authorName: name,
+ actorKind,
+ })
+ .onConflictDoNothing()
+ .returning({ id: schema.documentComments.id });
+ if (!inserted.length)
+ throw new Error("The comment ID collided with another comment");
- const mentions = parseMentions(args.mentions);
- const mentionsJson = mentions.length > 0 ? JSON.stringify(mentions) : null;
-
- await db.insert(schema.documentComments).values({
- id,
- ownerEmail,
- documentId,
- threadId,
- parentId,
- content,
- quotedText: args.quotedText ?? null,
- anchorPrefix: args.anchorPrefix ?? null,
- anchorSuffix: args.anchorSuffix ?? null,
- anchorStartOffset: args.anchorStartOffset ?? null,
- mentionsJson,
- authorEmail: email,
- authorName: name,
- });
+ return true;
+ });
+ if (!created) return { id, threadId, notified: 0, duplicate: true };
- const notified = await notifyDocumentComment({
- documentId,
- documentTitle: (access.resource.title as string | null) ?? "",
- orgId: (access.resource.orgId as string | null) ?? null,
- threadId,
- ownerEmail,
- authorEmail: email,
- authorName: name,
- content,
- mentions,
- isReply: Boolean(parentId ?? args.threadId),
- });
+ const notified = await notifyDocumentComment({
+ documentId,
+ documentTitle: (access.resource.title as string | null) ?? "",
+ orgId: (access.resource.orgId as string | null) ?? null,
+ threadId,
+ ownerEmail,
+ authorEmail: email,
+ authorName: name,
+ content,
+ mentions,
+ isReply: Boolean(parentId ?? args.threadId),
+ });
- return { id, threadId, notified };
- },
+ return { id, threadId, notified };
+}
+
+export default defineAction({
+ description:
+ "Add a comment to a document. Comment text supports inline Markdown for emphasis, inline code, links, and line breaks; headings are flattened. To reply, provide both threadId and parentId; omit both to start a thread.",
+ deferLoading: false,
+ mcpTool: true,
+ schema: commentSchema,
+ run: (args, ctx) => addCommentWithGuard(args, ctx),
});
diff --git a/templates/content/actions/apply-comment-ai-request.ts b/templates/content/actions/apply-comment-ai-request.ts
new file mode 100644
index 00000000000..4eba9506370
--- /dev/null
+++ b/templates/content/actions/apply-comment-ai-request.ts
@@ -0,0 +1,151 @@
+import { defineAction } from "@agent-native/core/action";
+import { and, eq } from "drizzle-orm";
+import { z } from "zod";
+
+import { getDb, schema } from "../server/db/index.js";
+import {
+ assertCommentAiSourceUnchanged,
+ commentThreadDigest,
+ requireCommentAiRequest,
+ retainCommentAiPayload,
+ serializeCommentAiRequest,
+ updateCommentAiRequest,
+} from "../server/lib/comment-ai.js";
+import {
+ documentContentHash,
+ type DocumentEditMutationResult,
+} from "./_document-edit-mutation.js";
+import addComment from "./add-comment.js";
+import editDocument from "./edit-document.js";
+
+const payloadSchema = z.object({
+ edits: z
+ .array(
+ z.object({
+ find: z.string().min(1).max(24000),
+ replace: z.string().max(24000),
+ }),
+ )
+ .min(1)
+ .max(20),
+ summary: z
+ .string()
+ .trim()
+ .min(1)
+ .max(2000)
+ .describe("Concise receipt explaining the applied change"),
+});
+export default defineAction({
+ description:
+ "Apply the requested exact edits, verify the saved revision, post one receipt and resolve the original thread. On conflict the thread stays open. Retries replay the retained edit and resume unfinished steps; never claim rollback of an applied edit.",
+ schema: payloadSchema,
+ run: async (args, ctx) => {
+ const request = await requireCommentAiRequest("apply-resolve");
+ if (request.status === "resolved")
+ return serializeCommentAiRequest(request);
+ let result = serializeCommentAiRequest(request).result ?? {};
+ try {
+ await assertCommentAiSourceUnchanged(request);
+ const payload = payloadSchema.parse(
+ await retainCommentAiPayload(request, args),
+ );
+ const edit = (await editDocument.run(
+ {
+ id: request.documentId,
+ edits: payload.edits,
+ baseRevision: request.baseRevision,
+ idempotencyKey: `comment-ai:${request.id}:edit`,
+ },
+ { ...ctx, caller: "tool" },
+ )) as DocumentEditMutationResult;
+ if (edit.receipt.outcome !== "applied" || !edit.receipt.readback.verified)
+ throw new Error(
+ "No verified change was applied; the comment remains open",
+ );
+ result = { ...result, editApplied: true };
+ await updateCommentAiRequest(request, { status: "running", result });
+ const reply = await addComment.run(
+ {
+ documentId: request.documentId,
+ threadId: request.threadId,
+ parentId: request.rootCommentId,
+ content: payload.summary,
+ idempotencyKey: `comment-ai:${request.id}:receipt`,
+ },
+ ctx,
+ );
+ result = { ...result, commentId: reply.id };
+ await updateCommentAiRequest(request, { status: "running", result });
+ await getDb().transaction(async (tx) => {
+ const [document] = await tx
+ .select()
+ .from(schema.documents)
+ .where(
+ and(
+ eq(schema.documents.id, request.documentId),
+ eq(schema.documents.ownerEmail, request.ownerEmail),
+ ),
+ )
+ .for("update");
+ if (
+ !document ||
+ documentContentHash(document.content) !== edit.receipt.hashes.after
+ )
+ throw new Error(
+ "The Page changed after the edit was saved; the comment remains open for review",
+ );
+ const comments = await tx
+ .select()
+ .from(schema.documentComments)
+ .where(
+ and(
+ eq(schema.documentComments.documentId, request.documentId),
+ eq(schema.documentComments.threadId, request.threadId),
+ eq(schema.documentComments.ownerEmail, request.ownerEmail),
+ ),
+ )
+ .for("update");
+ if (
+ commentThreadDigest(comments.filter((c) => c.id !== reply.id)) !==
+ request.threadDigest
+ )
+ throw new Error(
+ "The comment changed after the edit was saved; its thread remains open for review",
+ );
+ await tx
+ .update(schema.documentComments)
+ .set({ resolved: 1, updatedAt: new Date().toISOString() })
+ .where(
+ and(
+ eq(schema.documentComments.documentId, request.documentId),
+ eq(schema.documentComments.threadId, request.threadId),
+ eq(schema.documentComments.ownerEmail, request.ownerEmail),
+ ),
+ );
+ await tx
+ .update(schema.commentAiRequests)
+ .set({
+ status: "resolved",
+ resultJson: JSON.stringify({ ...result, resolved: true }),
+ error: null,
+ updatedAt: new Date().toISOString(),
+ })
+ .where(eq(schema.commentAiRequests.id, request.id));
+ });
+ return await updateCommentAiRequest(request, {
+ status: "resolved",
+ result: { ...result, resolved: true },
+ });
+ } catch (error) {
+ await updateCommentAiRequest(request, {
+ status: "needs-review",
+ result,
+ error:
+ error instanceof Error
+ ? error.message
+ : "The operation could not be completed",
+ });
+ throw error;
+ }
+ },
+});
diff --git a/templates/content/actions/comment-ai-flow.test.ts b/templates/content/actions/comment-ai-flow.test.ts
new file mode 100644
index 00000000000..32e1fdc9ee0
--- /dev/null
+++ b/templates/content/actions/comment-ai-flow.test.ts
@@ -0,0 +1,425 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const state = vi.hoisted(() => ({
+ request: {} as Record,
+ source: {} as Record,
+ updates: [] as Array>,
+ retainedPayload: undefined as unknown,
+ transactionDigest: "thread-digest",
+ documentRevision: "base-revision",
+ transactionError: null as Error | null,
+ resolvedRows: false,
+}));
+
+const mocks = vi.hoisted(() => ({
+ addComment: vi.fn(),
+ editDocument: vi.fn(),
+ createSuggestion: vi.fn(),
+ assertSourceUnchanged: vi.fn(),
+ requireRequest: vi.fn(),
+ updateRequest: vi.fn(),
+ transaction: vi.fn(),
+}));
+
+function receipt() {
+ const request = state.request;
+ return {
+ requestId: request.id,
+ documentId: request.documentId,
+ threadId: request.threadId,
+ rootCommentId: request.rootCommentId,
+ intent: request.intent,
+ status: request.status,
+ runId: request.runId ?? null,
+ agentThreadId: request.agentThreadId ?? null,
+ result: request.result ?? null,
+ error: request.error ?? null,
+ createdAt: request.createdAt,
+ updatedAt: request.updatedAt,
+ };
+}
+
+vi.mock("../server/lib/comment-ai.js", () => ({
+ assertCommentAiSourceUnchanged: (...args: unknown[]) =>
+ mocks.assertSourceUnchanged(...args),
+ commentThreadDigest: () => state.transactionDigest,
+ requireCommentAiRequest: (...args: unknown[]) =>
+ mocks.requireRequest(...args),
+ retainCommentAiPayload: async (_request: unknown, payload: unknown) => {
+ state.retainedPayload ??= payload;
+ state.request.payloadJson = JSON.stringify(state.retainedPayload);
+ state.request.status = "running";
+ return state.retainedPayload;
+ },
+ serializeCommentAiRequest: () => receipt(),
+ updateCommentAiRequest: (...args: unknown[]) => mocks.updateRequest(...args),
+}));
+
+vi.mock("./add-comment.js", () => ({
+ default: { run: (...args: unknown[]) => mocks.addComment(...args) },
+ commentIdForIdempotency: () => "reply-id",
+ addCommentWithGuard: async (args: unknown, ctx: unknown, guard: any) => {
+ const { getDb } = await import("../server/db/index.js");
+ await getDb().transaction(guard);
+ return mocks.addComment(args, ctx);
+ },
+}));
+
+vi.mock("./edit-document.js", () => ({
+ default: { run: (...args: unknown[]) => mocks.editDocument(...args) },
+}));
+
+vi.mock(
+ "@agent-native/core/review/suggestions/actions/create-resource-suggestion",
+ () => ({
+ default: { run: (...args: unknown[]) => mocks.createSuggestion(...args) },
+ }),
+);
+
+vi.mock("../server/lib/suggested-edits.js", () => ({
+ CONTENT_DOCUMENT_SUGGESTION_ADAPTER: "content-document",
+}));
+
+vi.mock("../shared/document-text-edits.js", () => ({
+ resolveDocumentTextEdits: (
+ content: string,
+ edits: Array<{ find: string; replace: string }>,
+ ) => ({
+ ok: true,
+ content: content.replace(edits[0].find, edits[0].replace),
+ }),
+}));
+
+vi.mock("../app/components/editor/suggestions/markdown-operation.js", () => ({
+ markdownSuggestionOperation: () => ({ type: "replace", from: 0, to: 5 }),
+}));
+
+vi.mock("./_document-edit-mutation.js", () => ({
+ documentContentHash: (content: string) => `hash:${content}`,
+ documentRevisionToken: () => state.documentRevision,
+}));
+
+vi.mock("../server/db/index.js", () => {
+ const schema = {
+ documents: { id: "documents.id", ownerEmail: "documents.ownerEmail" },
+ documentComments: {
+ documentId: "comments.documentId",
+ threadId: "comments.threadId",
+ ownerEmail: "comments.ownerEmail",
+ },
+ commentAiRequests: { id: "requests.id" },
+ };
+ const tx = {
+ select: () => ({
+ from: (table: unknown) => ({
+ where: () => {
+ const rows =
+ table === schema.documents
+ ? [state.source.document]
+ : state.source.comments;
+ return Object.assign(Promise.resolve(rows), {
+ for: async () => rows,
+ });
+ },
+ }),
+ }),
+ update: (table: unknown) => ({
+ set: (patch: Record) => ({
+ where: async () => {
+ if (table === schema.documentComments && patch.resolved === 1) {
+ state.resolvedRows = true;
+ }
+ },
+ }),
+ }),
+ };
+ const db = {
+ transaction: (...args: unknown[]) => mocks.transaction(tx, ...args),
+ };
+ return { getDb: () => db, schema };
+});
+
+import applyRequest from "./apply-comment-ai-request.js";
+import createSuggestion from "./create-comment-ai-suggestion.js";
+import replyRequest from "./reply-to-comment-ai-request.js";
+
+const ctx = {
+ caller: "tool" as const,
+ userEmail: "agent@example.test",
+ actionName: "comment-ai-operation",
+};
+
+function run(
+ action: { run: (args: any, ctx: any) => Promise },
+ args: unknown,
+) {
+ return action.run(args, ctx);
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ state.documentRevision = "base-revision";
+ state.request = {
+ id: "11111111-1111-4111-8111-111111111111",
+ ownerEmail: "owner@example.test",
+ requesterEmail: "agent@example.test",
+ documentId: "page-1",
+ threadId: "thread-1",
+ rootCommentId: "comment-1",
+ fieldId: "body",
+ intent: "reply",
+ status: "running",
+ threadDigest: "thread-digest",
+ payloadJson: null,
+ result: null,
+ error: null,
+ baseRevision: "base-revision",
+ suggestionRevision: "suggestion-revision",
+ createdAt: "2026-09-08T00:00:00.000Z",
+ updatedAt: "2026-09-08T00:00:00.000Z",
+ };
+ state.source = {
+ document: {
+ id: "page-1",
+ content: "Before text",
+ bodyRevision: 1,
+ updatedAt: "suggestion-revision",
+ },
+ comments: [{ id: "comment-1", parentId: null, content: "Please fix" }],
+ };
+ state.updates = [];
+ state.retainedPayload = undefined;
+ state.transactionDigest = "thread-digest";
+ state.transactionError = null;
+ state.resolvedRows = false;
+
+ mocks.requireRequest.mockImplementation(async (intent?: string) => {
+ if (intent && state.request.intent !== intent) {
+ throw new Error(
+ "This operation is not permitted by the selected comment intent",
+ );
+ }
+ return state.request;
+ });
+ mocks.assertSourceUnchanged.mockImplementation(async () => state.source);
+ mocks.updateRequest.mockImplementation(
+ async (_request: unknown, update: Record) => {
+ state.updates.push(update);
+ state.request.status = update.status;
+ if (update.result !== undefined) state.request.result = update.result;
+ state.request.error = update.error ?? null;
+ return receipt();
+ },
+ );
+ mocks.addComment.mockResolvedValue({ id: "ai-receipt-1", duplicate: false });
+ mocks.createSuggestion.mockResolvedValue({ id: "suggestion-1" });
+ mocks.editDocument.mockImplementation(async () => {
+ state.source.document.content = "After text";
+ return {
+ receipt: {
+ outcome: "applied",
+ readback: { verified: true },
+ hashes: { after: "hash:After text" },
+ },
+ };
+ });
+ mocks.transaction.mockImplementation(async (tx: any, callback: any) => {
+ if (state.transactionError) throw state.transactionError;
+ return callback(tx);
+ });
+});
+
+describe("comment AI dedicated action boundaries", () => {
+ it.each([
+ [
+ "reply",
+ createSuggestion,
+ { summary: "Fix", find: "Before", replace: "After" },
+ ],
+ [
+ "reply",
+ applyRequest,
+ { summary: "Fixed", edits: [{ find: "Before", replace: "After" }] },
+ ],
+ ["suggest", replyRequest, { content: "Answer" }],
+ ])(
+ "rejects a %s request sent to a different operation",
+ async (_intent, action, args) => {
+ state.request.intent = _intent;
+
+ await expect(run(action as any, args)).rejects.toThrow(
+ "not permitted by the selected comment intent",
+ );
+
+ expect(mocks.assertSourceUnchanged).not.toHaveBeenCalled();
+ expect(mocks.addComment).not.toHaveBeenCalled();
+ expect(mocks.createSuggestion).not.toHaveBeenCalled();
+ expect(mocks.editDocument).not.toHaveBeenCalled();
+ expect(state.resolvedRows).toBe(false);
+ },
+ );
+
+ it("posts a reply only to the bound page, thread, and root comment", async () => {
+ state.request.intent = "reply";
+
+ const result = await run(replyRequest as any, { content: "The answer" });
+
+ expect(mocks.addComment).toHaveBeenCalledWith(
+ {
+ documentId: "page-1",
+ threadId: "thread-1",
+ parentId: "comment-1",
+ content: "The answer",
+ idempotencyKey: "comment-ai:11111111-1111-4111-8111-111111111111:reply",
+ },
+ ctx,
+ );
+ expect(result).toMatchObject({
+ status: "replied",
+ result: { commentId: "ai-receipt-1" },
+ });
+ expect(mocks.createSuggestion).not.toHaveBeenCalled();
+ expect(mocks.editDocument).not.toHaveBeenCalled();
+ expect(state.resolvedRows).toBe(false);
+ });
+
+ it.each(["Page", "comment"])(
+ "rejects a %s change after context was read and before reply commit",
+ async (changed) => {
+ state.request.intent = "reply";
+ if (changed === "Page") state.documentRevision = "new-revision";
+ else state.transactionDigest = "new-thread-digest";
+ await expect(
+ run(replyRequest as any, { content: "Stale answer" }),
+ ).rejects.toThrow(/changed during this request/);
+ expect(mocks.addComment).not.toHaveBeenCalled();
+ expect(state.request.status).toBe("needs-review");
+ },
+ );
+
+ it("creates one reviewable suggestion with source-comment provenance and leaves feedback open", async () => {
+ state.request.intent = "suggest";
+
+ const result = await run(createSuggestion as any, {
+ summary: "Clarify [this]",
+ find: "Before",
+ replace: "After",
+ });
+
+ expect(mocks.createSuggestion).toHaveBeenCalledWith(
+ expect.objectContaining({
+ resourceType: "document",
+ resourceId: "page-1",
+ baseRevision: "suggestion-revision",
+ idempotencyKey:
+ "comment-ai:11111111-1111-4111-8111-111111111111:suggestion",
+ metadata: {
+ sourceCommentId: "comment-1",
+ sourceThreadId: "thread-1",
+ sourceUrl: "/page/page-1?comment=thread-1",
+ commentAiRequestId: "11111111-1111-4111-8111-111111111111",
+ },
+ }),
+ ctx,
+ );
+ expect(mocks.addComment).toHaveBeenCalledWith(
+ expect.objectContaining({
+ documentId: "page-1",
+ threadId: "thread-1",
+ parentId: "comment-1",
+ idempotencyKey:
+ "comment-ai:11111111-1111-4111-8111-111111111111:receipt",
+ content: "[Clarify this](/page/page-1?suggestion=suggestion-1)",
+ }),
+ ctx,
+ );
+ expect(result).toMatchObject({
+ status: "suggested",
+ result: { suggestionId: "suggestion-1", commentId: "ai-receipt-1" },
+ });
+ expect(mocks.editDocument).not.toHaveBeenCalled();
+ expect(state.resolvedRows).toBe(false);
+ });
+});
+
+describe("apply-and-resolve partial failure recovery", () => {
+ const args = {
+ summary: "Applied the requested correction",
+ edits: [{ find: "Before", replace: "After" }],
+ };
+
+ beforeEach(() => {
+ state.request.intent = "apply-resolve";
+ });
+
+ it("keeps the thread open when its source becomes stale after a verified edit", async () => {
+ state.transactionDigest = "stale-thread";
+
+ await expect(run(applyRequest as any, args)).rejects.toThrow(
+ "comment changed after the edit was saved",
+ );
+
+ expect(state.request.result).toEqual({
+ editApplied: true,
+ commentId: "ai-receipt-1",
+ });
+ expect(state.request.status).toBe("needs-review");
+ expect(state.resolvedRows).toBe(false);
+ });
+
+ it("retries an interrupted post-edit flow with the same edit and receipt keys, without changing targets", async () => {
+ state.transactionError = new Error("transaction interrupted");
+ await expect(run(applyRequest as any, args)).rejects.toThrow(
+ "transaction interrupted",
+ );
+
+ state.transactionError = null;
+ const result = await run(applyRequest as any, {
+ summary: "Different retry text must not replace retained payload",
+ edits: [{ find: "Wrong", replace: "Wrong" }],
+ });
+
+ expect(mocks.editDocument).toHaveBeenCalledTimes(2);
+ expect(mocks.editDocument.mock.calls.map(([input]) => input)).toEqual([
+ {
+ id: "page-1",
+ edits: args.edits,
+ baseRevision: "base-revision",
+ idempotencyKey: "comment-ai:11111111-1111-4111-8111-111111111111:edit",
+ },
+ {
+ id: "page-1",
+ edits: args.edits,
+ baseRevision: "base-revision",
+ idempotencyKey: "comment-ai:11111111-1111-4111-8111-111111111111:edit",
+ },
+ ]);
+ expect(mocks.addComment.mock.calls.map(([input]) => input)).toEqual([
+ expect.objectContaining({
+ documentId: "page-1",
+ threadId: "thread-1",
+ parentId: "comment-1",
+ content: args.summary,
+ idempotencyKey:
+ "comment-ai:11111111-1111-4111-8111-111111111111:receipt",
+ }),
+ expect.objectContaining({
+ documentId: "page-1",
+ threadId: "thread-1",
+ parentId: "comment-1",
+ content: args.summary,
+ idempotencyKey:
+ "comment-ai:11111111-1111-4111-8111-111111111111:receipt",
+ }),
+ ]);
+ expect(state.resolvedRows).toBe(true);
+ expect(result).toMatchObject({
+ status: "resolved",
+ result: {
+ editApplied: true,
+ commentId: "ai-receipt-1",
+ resolved: true,
+ },
+ });
+ });
+});
diff --git a/templates/content/actions/create-comment-ai-suggestion.ts b/templates/content/actions/create-comment-ai-suggestion.ts
new file mode 100644
index 00000000000..a9be5e8a477
--- /dev/null
+++ b/templates/content/actions/create-comment-ai-suggestion.ts
@@ -0,0 +1,119 @@
+import { defineAction } from "@agent-native/core/action";
+import createSuggestion from "@agent-native/core/review/suggestions/actions/create-resource-suggestion";
+import { z } from "zod";
+
+import { markdownSuggestionOperation } from "../app/components/editor/suggestions/markdown-operation.js";
+import {
+ assertCommentAiSourceUnchanged,
+ requireCommentAiRequest,
+ retainCommentAiPayload,
+ serializeCommentAiRequest,
+ updateCommentAiRequest,
+} from "../server/lib/comment-ai.js";
+import { CONTENT_DOCUMENT_SUGGESTION_ADAPTER } from "../server/lib/suggested-edits.js";
+import { resolveDocumentTextEdits } from "../shared/document-text-edits.js";
+import { documentRevisionToken } from "./_document-edit-mutation.js";
+import addComment from "./add-comment.js";
+
+const payloadSchema = z.object({
+ summary: z.string().trim().min(1).max(500),
+ find: z
+ .string()
+ .min(1)
+ .max(24000)
+ .describe("Exact unique text to propose replacing"),
+ replace: z
+ .string()
+ .max(24000)
+ .describe("Proposed replacement; canonical text remains unchanged"),
+});
+export default defineAction({
+ description:
+ "Create one real anchored suggestion with Accept/Reject controls, linked to the original feedback. Provide one independently reviewable exact replacement. This leaves canonical text and original comment resolution unchanged.",
+ schema: payloadSchema,
+ run: async (args, ctx) => {
+ const request = await requireCommentAiRequest("suggest");
+ if (request.status === "suggested")
+ return serializeCommentAiRequest(request);
+ try {
+ const { document } = await assertCommentAiSourceUnchanged(request);
+ let retained: typeof args & {
+ operation: NonNullable>;
+ };
+ if (request.payloadJson) {
+ retained = JSON.parse(request.payloadJson) as typeof retained;
+ if (!retained.operation)
+ throw new Error("The retained proposal operation is unavailable");
+ } else {
+ if (
+ documentRevisionToken(document.bodyRevision, document.content) !==
+ request.baseRevision
+ )
+ throw new Error("The Page changed before this proposal was created");
+ const proposed = resolveDocumentTextEdits(document.content, [
+ { find: args.find, replace: args.replace },
+ ]);
+ if (!proposed.ok)
+ throw new Error(
+ `The proposal target is ${proposed.error.kind}; no suggestion was created`,
+ );
+ const operation = markdownSuggestionOperation(
+ document.content,
+ proposed.content,
+ );
+ if (!operation)
+ throw new Error("The proposal must change the selected text");
+ retained = await retainCommentAiPayload(request, {
+ ...args,
+ operation,
+ });
+ }
+ const payload = payloadSchema.parse(retained);
+ const suggestion = await createSuggestion.run(
+ {
+ resourceType: "document",
+ resourceId: request.documentId,
+ adapterKind: CONTENT_DOCUMENT_SUGGESTION_ADAPTER,
+ baseRevision: request.suggestionRevision,
+ summary: payload.summary,
+ idempotencyKey: `comment-ai:${request.id}:suggestion`,
+ operations: [retained.operation],
+ metadata: {
+ sourceCommentId: request.rootCommentId,
+ sourceThreadId: request.threadId,
+ sourceUrl: `/page/${encodeURIComponent(request.documentId)}?comment=${encodeURIComponent(request.threadId)}`,
+ commentAiRequestId: request.id,
+ },
+ },
+ ctx,
+ );
+ await updateCommentAiRequest(request, {
+ status: "running",
+ result: { suggestionId: suggestion.id },
+ });
+ const reply = await addComment.run(
+ {
+ documentId: request.documentId,
+ threadId: request.threadId,
+ parentId: request.rootCommentId,
+ content: `[${payload.summary.replace(/[\[\]]/g, "")}](/page/${encodeURIComponent(request.documentId)}?suggestion=${encodeURIComponent(suggestion.id)})`,
+ idempotencyKey: `comment-ai:${request.id}:receipt`,
+ },
+ ctx,
+ );
+ return await updateCommentAiRequest(request, {
+ status: "suggested",
+ result: { suggestionId: suggestion.id, commentId: reply.id },
+ });
+ } catch (error) {
+ await updateCommentAiRequest(request, {
+ status: "needs-review",
+ error:
+ error instanceof Error
+ ? error.message
+ : "The proposal could not be completed",
+ });
+ throw error;
+ }
+ },
+});
diff --git a/templates/content/actions/get-comment-ai-context.ts b/templates/content/actions/get-comment-ai-context.ts
new file mode 100644
index 00000000000..092e66d3ba6
--- /dev/null
+++ b/templates/content/actions/get-comment-ai-context.ts
@@ -0,0 +1,62 @@
+import { defineAction } from "@agent-native/core/action";
+import { z } from "zod";
+
+import {
+ assertCommentAiSourceUnchanged,
+ requireCommentAiRequest,
+ serializeCommentAiRequest,
+ updateCommentAiRequest,
+} from "../server/lib/comment-ai.js";
+import { documentRevisionToken } from "./_document-edit-mutation.js";
+
+export default defineAction({
+ description:
+ "Read the exact comment conversation, submitted snapshot, Page body and revision for this scoped request. Read this before the dedicated operation. An existing result is durable; do not duplicate it.",
+ schema: z.object({}),
+ run: async () => {
+ const request = await requireCommentAiRequest();
+ const receipt = serializeCommentAiRequest(request);
+ if (["replied", "suggested", "resolved"].includes(request.status))
+ return { request: receipt };
+ try {
+ const { document, comments, root } =
+ await assertCommentAiSourceUnchanged(request);
+ if (
+ !request.payloadJson &&
+ documentRevisionToken(document.bodyRevision, document.content) !==
+ request.baseRevision
+ )
+ throw new Error(
+ "The Page changed after this comment request was submitted. Start a fresh request to use the new revision.",
+ );
+ await updateCommentAiRequest(request, { status: "running" });
+ return {
+ request: receipt,
+ fieldId: request.fieldId,
+ title: document.title,
+ content: document.content,
+ baseRevision: request.baseRevision,
+ quotedText: root.quotedText,
+ conversation: comments.map((c) => ({
+ id: c.id,
+ parentId: c.parentId,
+ content: c.content,
+ actorKind: c.actorKind,
+ author: c.authorName,
+ })),
+ submittedConversation: JSON.parse(request.snapshotJson),
+ retainedOperation:
+ request.payloadJson === null ? null : JSON.parse(request.payloadJson),
+ };
+ } catch (error) {
+ await updateCommentAiRequest(request, {
+ status: "needs-review",
+ error:
+ error instanceof Error
+ ? error.message
+ : "Comment context could not be read",
+ });
+ throw error;
+ }
+ },
+});
diff --git a/templates/content/actions/list-comment-ai-requests.ts b/templates/content/actions/list-comment-ai-requests.ts
new file mode 100644
index 00000000000..345731ad144
--- /dev/null
+++ b/templates/content/actions/list-comment-ai-requests.ts
@@ -0,0 +1,14 @@
+import { defineAction } from "@agent-native/core/action";
+import { z } from "zod";
+
+import { listCommentAiRequests } from "../server/lib/comment-ai.js";
+
+export default defineAction({
+ description:
+ "Read the current user's saved Ask AI requests and partial results for a Page.",
+ readOnly: true,
+ toolCallable: false,
+ http: { method: "GET" },
+ schema: z.object({ documentId: z.string().min(1) }),
+ run: ({ documentId }) => listCommentAiRequests(documentId),
+});
diff --git a/templates/content/actions/list-comments.ts b/templates/content/actions/list-comments.ts
index 92f9d9dd480..8beeb688583 100644
--- a/templates/content/actions/list-comments.ts
+++ b/templates/content/actions/list-comments.ts
@@ -71,11 +71,15 @@ export default defineAction({
row.anchorStartOffset == null ? null : Number(row.anchorStartOffset),
mentions: parseMentions(row.mentionsJson),
author_email: row.authorEmail,
- author_name: resolveUserProfileName(
- row.authorEmail,
- row.authorName,
- profiles.get(row.authorEmail.toLowerCase())?.name,
- ),
+ actor_kind: row.actorKind,
+ author_name:
+ row.actorKind === "agent"
+ ? "AI Agent"
+ : resolveUserProfileName(
+ row.authorEmail,
+ row.authorName,
+ profiles.get(row.authorEmail.toLowerCase())?.name,
+ ),
resolved: row.resolved,
created_at: row.createdAt,
updated_at: row.updatedAt,
diff --git a/templates/content/actions/reply-to-comment-ai-request.ts b/templates/content/actions/reply-to-comment-ai-request.ts
new file mode 100644
index 00000000000..a341f750214
--- /dev/null
+++ b/templates/content/actions/reply-to-comment-ai-request.ts
@@ -0,0 +1,99 @@
+import { defineAction } from "@agent-native/core/action";
+import { and, eq } from "drizzle-orm";
+import { z } from "zod";
+
+import { schema } from "../server/db/index.js";
+import {
+ assertCommentAiSourceUnchanged,
+ commentThreadDigest,
+ requireCommentAiRequest,
+ retainCommentAiPayload,
+ serializeCommentAiRequest,
+ updateCommentAiRequest,
+} from "../server/lib/comment-ai.js";
+import { documentRevisionToken } from "./_document-edit-mutation.js";
+import { addCommentWithGuard, commentIdForIdempotency } from "./add-comment.js";
+
+const payloadSchema = z.object({
+ content: z
+ .string()
+ .trim()
+ .min(1)
+ .max(12000)
+ .describe("The answer to post in the original comment thread"),
+});
+export default defineAction({
+ description:
+ "Post one AI answer in this request's original thread. This operation cannot edit the Page or resolve feedback. Repeated calls recover the same answer.",
+ schema: payloadSchema,
+ run: async (args, ctx) => {
+ const request = await requireCommentAiRequest("reply");
+ if (request.status === "replied") return serializeCommentAiRequest(request);
+ try {
+ await assertCommentAiSourceUnchanged(request);
+ const payload = payloadSchema.parse(
+ await retainCommentAiPayload(request, args),
+ );
+ const reply = await addCommentWithGuard(
+ {
+ documentId: request.documentId,
+ threadId: request.threadId,
+ parentId: request.rootCommentId,
+ content: payload.content,
+ idempotencyKey: `comment-ai:${request.id}:reply`,
+ },
+ ctx,
+ async (tx) => {
+ const [document] = await tx
+ .select()
+ .from(schema.documents)
+ .where(eq(schema.documents.id, request.documentId));
+ if (
+ !document ||
+ documentRevisionToken(document.bodyRevision, document.content) !==
+ request.baseRevision
+ )
+ throw new Error(
+ "The Page changed during this request; review the comment before retrying",
+ );
+ const comments = await tx
+ .select()
+ .from(schema.documentComments)
+ .where(
+ and(
+ eq(schema.documentComments.documentId, request.documentId),
+ eq(schema.documentComments.threadId, request.threadId),
+ ),
+ )
+ .for("update");
+ const receiptId = commentIdForIdempotency(
+ request.requesterEmail,
+ request.documentId,
+ `comment-ai:${request.id}:reply`,
+ );
+ if (
+ commentThreadDigest(
+ comments.filter((comment) => comment.id !== receiptId),
+ ) !== request.threadDigest
+ )
+ throw new Error(
+ "The comment changed during this request; review its latest replies",
+ );
+ },
+ );
+ return await updateCommentAiRequest(request, {
+ status: "replied",
+ result: { commentId: reply.id },
+ });
+ } catch (error) {
+ await updateCommentAiRequest(request, {
+ status: "needs-review",
+ error:
+ error instanceof Error
+ ? error.message
+ : "The reply could not be completed",
+ });
+ throw error;
+ }
+ },
+});
diff --git a/templates/content/actions/start-comment-ai-request.ts b/templates/content/actions/start-comment-ai-request.ts
new file mode 100644
index 00000000000..866abafe3c8
--- /dev/null
+++ b/templates/content/actions/start-comment-ai-request.ts
@@ -0,0 +1,21 @@
+import { defineAction } from "@agent-native/core/action";
+import { z } from "zod";
+
+import {
+ commentAiIntentSchema,
+ startCommentAiRequest,
+} from "../server/lib/comment-ai.js";
+
+export default defineAction({
+ description:
+ "Bind an Ask AI request to one original comment and the user's chosen intent.",
+ toolCallable: false,
+ schema: z.object({
+ requestId: z.string().uuid(),
+ documentId: z.string().min(1),
+ threadId: z.string().min(1),
+ rootCommentId: z.string().min(1),
+ intent: commentAiIntentSchema,
+ }),
+ run: startCommentAiRequest,
+});
diff --git a/templates/content/app/components/editor/CommentsSidebar.tsx b/templates/content/app/components/editor/CommentsSidebar.tsx
index 251372b994a..66765e046f8 100644
--- a/templates/content/app/components/editor/CommentsSidebar.tsx
+++ b/templates/content/app/components/editor/CommentsSidebar.tsx
@@ -1,4 +1,3 @@
-import { sendToAgentChat } from "@agent-native/core/client/agent-chat";
import { useAvatarUrl } from "@agent-native/core/client/hooks";
import { useT } from "@agent-native/core/client/i18n";
import {
@@ -10,9 +9,9 @@ import type {
ResourceSuggestion,
SuggestionDecision,
} from "@agent-native/core/review";
+import type { CommentAiIntent, CommentAiRequest } from "@shared/comment-ai";
import {
IconCheck,
- IconSparkles,
IconArrowUp,
IconArrowBackUp,
IconFilter,
@@ -28,6 +27,7 @@ import {
useCallback,
type RefObject,
} from "react";
+import { Link } from "react-router";
import { toast } from "sonner";
import {
@@ -65,6 +65,11 @@ import {
} from "@/hooks/use-mention-members";
import { cn } from "@/lib/utils";
+import {
+ CommentAiThreadActions,
+ latestCommentAiRequest,
+ type CommentAiController,
+} from "./comment-ai";
import type { CommentTextAnchor } from "./comment-anchors";
import { useCommentDraft, useCommentPanelSession } from "./comment-drafts";
import { CommentComposer, type MentionEntry } from "./CommentComposer";
@@ -150,6 +155,19 @@ function CommentAvatar({
);
}
+function CommentActorBadge({
+ actorKind,
+}: {
+ actorKind?: "human" | "agent" | null;
+}) {
+ const t = useT();
+ return actorKind === "agent" ? (
+
+ {t("comments.aiBadge")}
+
+ ) : null;
+}
+
function formatDate(dateStr: string) {
const d = new Date(dateStr);
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
@@ -387,6 +405,8 @@ interface CommentsSidebarProps {
suggestions?: ResourceSuggestion[];
canDecideSuggestions?: boolean;
decidingSuggestion?: boolean;
+ canSuggest?: boolean;
+ commentAi?: CommentAiController;
onDecideSuggestion?: (
suggestion: ResourceSuggestion,
decision: SuggestionDecision,
@@ -420,6 +440,8 @@ export function CommentsSidebar({
suggestions = [],
canDecideSuggestions = false,
decidingSuggestion = false,
+ canSuggest = false,
+ commentAi,
onDecideSuggestion,
visibleThreadId,
presentation = "inline",
@@ -651,17 +673,24 @@ export function CommentsSidebar({
);
};
- const handleSendToAI = (thread: CommentThread) => {
- const commentTexts = thread.comments
- .map((c) => `${c.author_name ?? c.author_email}: ${c.content}`)
- .join("\n");
- const context = thread.quotedText
- ? `${t("comments.agentRegardingText", { text: thread.quotedText })}\n\n`
- : "";
- sendToAgentChat({
- message: t("comments.agentHelp"),
- context: `${context}${t("comments.agentThreadHeader")}\n${commentTexts}`,
- });
+ const handleStartCommentAi = async (
+ thread: CommentThread,
+ intent: CommentAiIntent,
+ requestId?: string,
+ ) => {
+ if (!commentAi) return;
+ try {
+ await commentAi.start({
+ threadId: thread.threadId,
+ rootCommentId: thread.comments[0].id,
+ intent,
+ requestId,
+ });
+ } catch (error) {
+ toast.error(t("comments.aiFailed"), {
+ description: error instanceof Error ? error.message : undefined,
+ });
+ }
};
const [threadPositions, setThreadPositions] = useState<
@@ -856,6 +885,10 @@ export function CommentsSidebar({
const after = operation?.after as
| { changedText?: string }
| undefined;
+ const sourceUrl =
+ typeof suggestion.metadata?.sourceUrl === "string"
+ ? suggestion.metadata.sourceUrl
+ : null;
return (
{renderSuggestionText(after.changedText)}
) : null}
+ {sourceUrl ? (
+ event.stopPropagation()}
+ >
+ {t("comments.sourceComment")}
+
+ ) : null}
{canDecideSuggestions && suggestion.status === "pending" ? (