diff --git a/.changeset/comment-receipt-navigation.md b/.changeset/comment-receipt-navigation.md
new file mode 100644
index 00000000000..aa06552b5ca
--- /dev/null
+++ b/.changeset/comment-receipt-navigation.md
@@ -0,0 +1,5 @@
+---
+"@agent-native/core": patch
+---
+
+Allow app routing for safe inline Markdown links so comment suggestion receipts preserve the active workspace.
diff --git a/.changeset/comment-suggestion-transaction-reads.md b/.changeset/comment-suggestion-transaction-reads.md
new file mode 100644
index 00000000000..aec7b2c1e01
--- /dev/null
+++ b/.changeset/comment-suggestion-transaction-reads.md
@@ -0,0 +1,5 @@
+---
+"@agent-native/core": patch
+---
+
+Keep suggestion validation and feature flag reads on the active database transaction to avoid stalled local suggestion creation.
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/client/markdown/InlineMarkdown.spec.tsx b/packages/core/src/client/markdown/InlineMarkdown.spec.tsx
index 862eb5e387d..fc776ab01e9 100644
--- a/packages/core/src/client/markdown/InlineMarkdown.spec.tsx
+++ b/packages/core/src/client/markdown/InlineMarkdown.spec.tsx
@@ -47,6 +47,26 @@ describe("InlineMarkdown", () => {
expect(links[0]?.rel).toBe("noopener noreferrer");
});
+ it("lets the app route safe links without exposing unsafe URLs to its renderer", () => {
+ const renderLink = vi.fn((href, children, className) => (
+
+ {children}
+
+ ));
+ act(() =>
+ root.render(
+ ,
+ ),
+ );
+ const link = container.querySelector("a");
+ expect(link?.getAttribute("href")).toBe("/page/page-1?suggestion=s-1");
+ expect(link?.hasAttribute("target")).toBe(false);
+ expect(renderLink).toHaveBeenCalledTimes(1);
+ });
+
it("keeps headings and other block syntax out of compact surfaces", () => {
act(() => {
root.render(
diff --git a/packages/core/src/client/markdown/InlineMarkdown.tsx b/packages/core/src/client/markdown/InlineMarkdown.tsx
index c2d26a6d67b..96dcc802612 100644
--- a/packages/core/src/client/markdown/InlineMarkdown.tsx
+++ b/packages/core/src/client/markdown/InlineMarkdown.tsx
@@ -24,6 +24,11 @@ export interface InlineMarkdownProps {
linkClassName?: string;
codeClassName?: string;
inline?: boolean;
+ renderLink?: (
+ href: string,
+ children: ReactNode,
+ className: string,
+ ) => ReactNode;
protectedSpans?: readonly InlineMarkdownProtectedSpan[];
renderProtectedSpan?: (
span: InlineMarkdownProtectedSpan,
@@ -50,6 +55,7 @@ export function InlineMarkdown({
linkClassName,
codeClassName,
inline = false,
+ renderLink,
protectedSpans = [],
renderProtectedSpan,
}: InlineMarkdownProps) {
@@ -76,16 +82,18 @@ export function InlineMarkdown({
? defaultUrlTransform(normalizeInlineMarkdownHref(href))
: "";
if (!safeHref) return <>{children}>;
+ const anchorClassName = cn(
+ "text-primary underline-offset-2 hover:underline",
+ linkClassName,
+ );
+ if (renderLink) return renderLink(safeHref, children, anchorClassName);
return (
{children}
diff --git a/packages/core/src/client/review/ReviewThreadPanel.spec.tsx b/packages/core/src/client/review/ReviewThreadPanel.spec.tsx
index b9585487721..7ed2bc35b86 100644
--- a/packages/core/src/client/review/ReviewThreadPanel.spec.tsx
+++ b/packages/core/src/client/review/ReviewThreadPanel.spec.tsx
@@ -82,6 +82,7 @@ describe("ReviewThreadPanel sidebar layout", () => {
act(() => root.unmount());
container.remove();
rootComment.body = "Make the heading clearer";
+ (rootComment as ReviewComment).createdBy = "human";
const comment = rootComment as ReviewComment & {
resolutionNote?: string;
};
@@ -92,6 +93,22 @@ describe("ReviewThreadPanel sidebar layout", () => {
vi.unstubAllGlobals();
});
+ it("uses the localized agent label without displaying the acting human as author", () => {
+ (rootComment as ReviewComment).createdBy = "agent";
+ act(() => {
+ root.render(
+ ,
+ );
+ });
+ expect(container.textContent).toContain("KI");
+ expect(container.textContent).not.toContain("reviewer@example.com");
+ });
+
it("uses a flat container and progressively discloses reply and narrow actions", () => {
act(() => {
root.render(
diff --git a/packages/core/src/client/review/ReviewThreadPanel.tsx b/packages/core/src/client/review/ReviewThreadPanel.tsx
index 61dbcccdf26..55f7b8c5611 100644
--- a/packages/core/src/client/review/ReviewThreadPanel.tsx
+++ b/packages/core/src/client/review/ReviewThreadPanel.tsx
@@ -83,6 +83,7 @@ export interface ReviewThreadPanelProps {
moreActionsLabel?: string;
resolvedLabel?: string;
reviewerLabel?: string;
+ agentLabel?: string;
onSelectThread?: (thread: ReviewThread) => void;
onCommentCreated?: (comment: ReviewComment) => void;
/** Allow signed-in commenters to reply. Omitted capabilities fail closed. */
@@ -124,6 +125,7 @@ export function ReviewThreadPanel({
moreActionsLabel = "More actions",
resolvedLabel = "Resolved",
reviewerLabel = "Reviewer",
+ agentLabel,
onSelectThread,
onCommentCreated,
canReply = false,
@@ -267,6 +269,7 @@ export function ReviewThreadPanel({
comment={thread.root}
resolvedLabel={resolvedLabel}
reviewerLabel={reviewerLabel}
+ agentLabel={agentLabel}
formatDate={formatDate}
/>
{thread.replies.length ? (
@@ -278,6 +281,7 @@ export function ReviewThreadPanel({
compact
resolvedLabel={resolvedLabel}
reviewerLabel={reviewerLabel}
+ agentLabel={agentLabel}
formatDate={formatDate}
/>
))}
@@ -477,15 +481,21 @@ function CommentBubble({
compact = false,
resolvedLabel,
reviewerLabel,
+ agentLabel,
formatDate,
}: {
comment: ReviewComment;
compact?: boolean;
resolvedLabel: string;
reviewerLabel: string;
+ agentLabel?: string;
formatDate: ReturnType["formatDate"];
}) {
- const author = comment.authorName ?? comment.authorEmail ?? reviewerLabel;
+ const author =
+ (comment.createdBy === "agent" ? agentLabel : undefined) ??
+ comment.authorName ??
+ comment.authorEmail ??
+ reviewerLabel;
const avatarUrl = useAvatarUrl(comment.authorEmail);
const resolutionNote =
comment.status === "resolved" ? getReviewResolutionNote(comment) : null;
diff --git a/packages/core/src/db/client.ts b/packages/core/src/db/client.ts
index 03c075e36e8..39d196b16a3 100644
--- a/packages/core/src/db/client.ts
+++ b/packages/core/src/db/client.ts
@@ -1,3 +1,4 @@
+import { AsyncLocalStorage } from "node:async_hooks";
import path from "path";
/**
@@ -2181,7 +2182,15 @@ export function annotateMissingTable(err: unknown, sql: unknown): unknown {
return err;
}
+const scopedDbExec = new AsyncLocalStorage();
+
+export function withDbExec(exec: DbExec, run: () => T): T {
+ return scopedDbExec.run(exec, run);
+}
+
export function getDbExec(): DbExec {
+ const scoped = scopedDbExec.getStore();
+ if (scoped) return scoped;
if (_exec) return _exec;
// Sanitize args because PostgreSQL parameters cannot be undefined.
diff --git a/packages/core/src/feature-flags/store.ts b/packages/core/src/feature-flags/store.ts
index c75d8ace359..a5ae4368258 100644
--- a/packages/core/src/feature-flags/store.ts
+++ b/packages/core/src/feature-flags/store.ts
@@ -1,4 +1,4 @@
-import { getDbExec } from "../db/client.js";
+import { getDbExec, type DbExec } from "../db/client.js";
import { getOrgSetting, mutateOrgSetting } from "../settings/org-settings.js";
import { getSetting, mutateSetting } from "../settings/store.js";
import {
@@ -19,6 +19,7 @@ export interface FeatureFlagRules {
}
export interface FeatureFlagScope {
+ transaction?: DbExec;
userEmail?: string;
/** Canonical authenticated identity. V1 callers use normalized email. */
userKey?: string;
@@ -170,7 +171,7 @@ export function normalizeFeatureFlagRules(value: unknown): FeatureFlagRules {
export async function getFeatureFlagRules(
key: string,
- scope: Pick,
+ scope: Pick,
): Promise {
if (!getFeatureFlagDefinition(key)) return defaultFeatureFlagRules();
// An organization-specific rule overrides the global rule. The fallback is
@@ -179,10 +180,12 @@ export async function getFeatureFlagRules(
// round trips; both settings rows are independent, so read them together.
const orgId = scope.orgId?.trim();
if (!orgId)
- return normalizeFeatureFlagRules(await getSetting(settingKey(key)));
+ return normalizeFeatureFlagRules(
+ await getSetting(settingKey(key), { transaction: scope.transaction }),
+ );
const [orgStored, globalStored] = await Promise.all([
- getOrgSetting(orgId, settingKey(key)),
- getSetting(settingKey(key)),
+ getOrgSetting(orgId, settingKey(key), { transaction: scope.transaction }),
+ getSetting(settingKey(key), { transaction: scope.transaction }),
]);
return normalizeFeatureFlagRules(orgStored ?? globalStored);
}
diff --git a/packages/core/src/review/registry.ts b/packages/core/src/review/registry.ts
index ccd03b77a50..c0f3e7cf239 100644
--- a/packages/core/src/review/registry.ts
+++ b/packages/core/src/review/registry.ts
@@ -42,6 +42,7 @@ function accessContextFrom(
return {
userEmail: ctx.userEmail ?? undefined,
orgId: ctx.orgId ?? undefined,
+ transaction: ctx.transaction as AccessContext["transaction"],
};
}
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..9ab00a4eba2
--- /dev/null
+++ b/packages/core/src/review/suggestions/actions.replay.spec.ts
@@ -0,0 +1,279 @@
+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 requestFingerprint: 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, requestFingerprint } : 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(",")}}`;
+}
+
+async function replayRequestFingerprint(): Promise {
+ const requestJson = 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,
+ },
+ ],
+ });
+ const digest = await globalThis.crypto.subtle.digest(
+ "SHA-256",
+ new TextEncoder().encode(requestJson),
+ );
+ return Array.from(new Uint8Array(digest), (byte) =>
+ byte.toString(16).padStart(2, "0"),
+ ).join("");
+}
+
+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(async () => {
+ vi.clearAllMocks();
+ prior = makePrior();
+ requestFingerprint = await replayRequestFingerprint();
+ 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("refuses creation when commenter access was revoked inside the transaction", async () => {
+ prior = null;
+ registerReviewableResource({
+ type: "doc",
+ resolveAccess: (_id, ctx) => ({
+ role: ctx?.transaction ? "viewer" : "commenter",
+ ownerEmail: "owner@example.com",
+ visibility: "private",
+ }),
+ });
+ await expect(
+ createResourceSuggestion.run(args, {
+ caller: "tool",
+ userEmail: "agent@example.com",
+ }),
+ ).rejects.toThrow("Not allowed");
+ expect(insertSuggestion).not.toHaveBeenCalled();
+ });
+
+ it("passes the active transaction to proposal validation", async () => {
+ prior = null;
+ await expect(
+ createResourceSuggestion.run(args, {
+ caller: "tool",
+ userEmail: "agent@example.com",
+ }),
+ ).rejects.toThrow("base revision");
+ expect(validateProposal).toHaveBeenCalledWith(
+ expect.objectContaining({
+ ctx: expect.objectContaining({ transaction }),
+ }),
+ );
+ });
+
+ 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..b1a238a695d 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,94 @@ 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))
+ );
+}
+
+async function creationRequestFingerprint(
+ input: Parameters[1],
+ authorEmail: string | null,
+ actorKind: ResourceSuggestion["actorKind"],
+): Promise {
+ const requestJson = 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),
+ });
+ const digest = await globalThis.crypto.subtle.digest(
+ "SHA-256",
+ new TextEncoder().encode(requestJson),
+ );
+ return Array.from(new Uint8Array(digest), (byte) =>
+ byte.toString(16).padStart(2, "0"),
+ ).join("");
+}
+
export const createResourceSuggestion = defineAction({
description:
"Create a typed pending suggestion without changing the canonical resource.",
@@ -57,24 +146,24 @@ export const createResourceSuggestion = defineAction({
return url ? { url, label: "Open suggestion" } : null;
},
run: async (args, ctx) => {
- const access = await assertReviewableResourceAccess(
+ await assertReviewableResourceAccess(
args.resourceType,
args.resourceId,
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 requestFingerprint = await creationRequestFingerprint(
+ args,
+ authorEmail,
+ actorKind,
+ );
const db = getDbExec();
await ensureSuggestionTables();
await ensureReviewTables();
@@ -83,20 +172,43 @@ 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.requestFingerprint !== null
+ ? creation.requestFingerprint !== requestFingerprint
+ : !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 creationAccess = await assertReviewableResourceAccess(
+ args.resourceType,
+ args.resourceId,
+ { ...(ctx as any), transaction: tx },
+ "commenter",
+ );
+ const adapterContext = {
+ ...(ctx as any),
+ suggestionAccess: creationAccess,
+ transaction: tx,
+ };
+ const operations =
+ (await adapter.validateProposal({ ...args, ctx: adapterContext })) ??
+ args.operations;
const created = await insertSuggestion(
{
resourceType: args.resourceType,
@@ -104,20 +216,25 @@ 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",
summary: args.summary,
- ownerEmail: access.ownerEmail ?? null,
- orgId: access.orgId ?? null,
- visibility: access.visibility ?? "private",
+ ownerEmail: creationAccess.ownerEmail ?? null,
+ orgId: creationAccess.orgId ?? null,
+ visibility: creationAccess.visibility ?? "private",
metadata: args.metadata ?? null,
operations,
},
tx,
);
- await recordSuggestionCreation(tx, args.idempotencyKey, created.id);
+ await recordSuggestionCreation(
+ tx,
+ args.idempotencyKey,
+ created.id,
+ requestFingerprint,
+ );
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..c477df5ede5 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?.requestFingerprint).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..fe0dd0434ee 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_fingerprint 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_fingerprint",
+ "ALTER TABLE agent_review_suggestion_creations ADD COLUMN IF NOT EXISTS request_fingerprint 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,42 @@ export async function ensureSuggestionTables(
export async function getSuggestionByCreationKey(
client: DbExec,
idempotencyKey: string,
-): Promise {
+): Promise<{
+ suggestion: ResourceSuggestion;
+ requestFingerprint: 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_fingerprint 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,
+ requestFingerprint:
+ row.request_fingerprint == null ? null : String(row.request_fingerprint),
+ };
}
export async function recordSuggestionCreation(
client: DbExec,
idempotencyKey: string,
suggestionId: string,
+ requestFingerprint: 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_fingerprint,created_at) VALUES (?,?,?,?)",
+ args: [
+ idempotencyKey,
+ suggestionId,
+ requestFingerprint,
+ new Date().toISOString(),
+ ],
});
}
diff --git a/packages/core/src/review/transaction-access.spec.ts b/packages/core/src/review/transaction-access.spec.ts
new file mode 100644
index 00000000000..fe0f7cc43f3
--- /dev/null
+++ b/packages/core/src/review/transaction-access.spec.ts
@@ -0,0 +1,71 @@
+import { PGlite } from "@electric-sql/pglite";
+import { pgTable, text } from "drizzle-orm/pg-core";
+import { expect, it } from "vitest";
+
+import { registerShareableResource } from "../sharing/registry.js";
+import { createSharesTable } from "../sharing/schema.js";
+import { assertReviewableResourceAccess } from "./registry.js";
+
+it("rechecks current share permissions on the active transaction", async () => {
+ const database = await PGlite.create("memory://");
+ const documents = pgTable("transaction_review_docs", {
+ id: text("id").primaryKey(),
+ ownerEmail: text("owner_email"),
+ orgId: text("org_id"),
+ visibility: text("visibility"),
+ });
+ const shares = createSharesTable("transaction_review_shares");
+ registerShareableResource({
+ type: "transaction-review-test",
+ displayName: "Document",
+ resourceTable: documents,
+ sharesTable: shares,
+ getDb: () => {
+ throw new Error("Opened a connection outside the transaction");
+ },
+ });
+ try {
+ await database.exec(`CREATE TABLE transaction_review_docs (id TEXT PRIMARY KEY, owner_email TEXT, org_id TEXT, visibility TEXT);
+ CREATE TABLE transaction_review_shares (resource_id TEXT, principal_type TEXT, principal_id TEXT, role TEXT);
+ INSERT INTO transaction_review_docs VALUES ('doc', 'owner@example.test', NULL, 'private');
+ INSERT INTO transaction_review_shares VALUES ('doc', 'user', 'editor@example.test', 'editor');`);
+ await database.transaction(async (connection) => {
+ const transaction = {
+ execute: async (input: string | { sql: string; args?: unknown[] }) => {
+ const result = await connection.query(
+ typeof input === "string" ? input : input.sql,
+ typeof input === "string" ? [] : input.args,
+ );
+ return {
+ rows: result.rows as Record[],
+ rowsAffected: result.affectedRows ?? 0,
+ };
+ },
+ };
+ const context = { userEmail: "editor@example.test", transaction };
+ expect(
+ (
+ await assertReviewableResourceAccess(
+ "transaction-review-test",
+ "doc",
+ context,
+ "editor",
+ )
+ ).role,
+ ).toBe("editor");
+ await connection.query(
+ "UPDATE transaction_review_shares SET role = 'viewer'",
+ );
+ await expect(
+ assertReviewableResourceAccess(
+ "transaction-review-test",
+ "doc",
+ context,
+ "editor",
+ ),
+ ).rejects.toThrow("Not allowed");
+ });
+ } finally {
+ await database.close();
+ }
+});
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/packages/core/src/settings/org-settings.ts b/packages/core/src/settings/org-settings.ts
index 8126a52607c..0d3699e0271 100644
--- a/packages/core/src/settings/org-settings.ts
+++ b/packages/core/src/settings/org-settings.ts
@@ -16,6 +16,7 @@ import {
deleteSettingsByPrefix,
listSettingsByPrefix,
type StoreWriteOptions,
+ type StoreReadOptions,
} from "./store.js";
function orgKey(orgId: string, key: string): string {
@@ -28,8 +29,9 @@ const ORG_PREFIX_RE = /^o:([^:]+):(.+)$/;
export async function getOrgSetting(
orgId: string,
key: string,
+ options?: StoreReadOptions,
): Promise | null> {
- return getSetting(orgKey(orgId, key));
+ return getSetting(orgKey(orgId, key), options);
}
/** Write an org-scoped setting. Always writes to the prefixed key. */
diff --git a/packages/core/src/settings/store.spec.ts b/packages/core/src/settings/store.spec.ts
index c9bc4bce2f1..912e3cb4d5a 100644
--- a/packages/core/src/settings/store.spec.ts
+++ b/packages/core/src/settings/store.spec.ts
@@ -148,3 +148,16 @@ describe("settings store", () => {
]);
});
});
+
+it("reads settings through a supplied transaction without another connection", async () => {
+ const execute = vi.fn(async () => ({
+ rows: [{ value: '{"enabled":true}' }],
+ rowsAffected: 0,
+ }));
+ rawClient.execute.mockClear();
+ expect(await getSetting("flag", { transaction: { execute } })).toEqual({
+ enabled: true,
+ });
+ expect(execute).toHaveBeenCalledOnce();
+ expect(rawClient.execute).not.toHaveBeenCalled();
+});
diff --git a/packages/core/src/settings/store.ts b/packages/core/src/settings/store.ts
index 7a85b80b0d5..c160afe3d29 100644
--- a/packages/core/src/settings/store.ts
+++ b/packages/core/src/settings/store.ts
@@ -1,6 +1,6 @@
import type { EventEmitter } from "node:events";
-import { getDbExec } from "../db/client.js";
+import { getDbExec, type DbExec } from "../db/client.js";
import { ensureIndexExists, ensureTableExists } from "../db/ddl-guard.js";
import { widenIntColumnsToBigInt } from "../db/widen-columns.js";
import { getRequestContext } from "../server/request-context.js";
@@ -100,19 +100,20 @@ export async function ensureTable(): Promise {
export interface StoreReadOptions {
/** Skip the per-request snapshot when a cross-request race must be checked. */
bypassCache?: boolean;
+ transaction?: DbExec;
}
export async function getSetting(
key: string,
options?: StoreReadOptions,
): Promise | null> {
- const cache = requestSettingsCache();
+ const cache = options?.transaction ? null : requestSettingsCache();
if (!options?.bypassCache && cache?.has(key)) {
const cached = cache.get(key);
return cached == null ? null : JSON.parse(cached);
}
- await ensureTable();
- const client = getDbExec();
+ if (!options?.transaction) await ensureTable();
+ const client = options?.transaction ?? getDbExec();
const table = settingsTable();
const { rows } = await client.execute({
sql: `SELECT value FROM ${table} WHERE key = ?`,
diff --git a/packages/core/src/sharing/access.spec.ts b/packages/core/src/sharing/access.spec.ts
index c31ecd65b70..f9358eda723 100644
--- a/packages/core/src/sharing/access.spec.ts
+++ b/packages/core/src/sharing/access.spec.ts
@@ -10,6 +10,7 @@ import {
assertAccess,
ForbiddenError,
resolveAccess,
+ resolveRegisteredAccessContext,
} from "./access.js";
import listResourceShares from "./actions/list-resource-shares.js";
import setResourceVisibility from "./actions/set-resource-visibility.js";
@@ -47,6 +48,29 @@ type Db = ReturnType;
let pglite: Awaited>;
let db: Db;
+it("preserves a transaction through resource-specific context normalization", () => {
+ const transaction = {
+ execute: vi.fn(async () => ({ rows: [], rowsAffected: 0 })),
+ };
+ const resolved = resolveRegisteredAccessContext(
+ {
+ type: "normalized-transaction-test",
+ resourceTable: docs,
+ sharesTable: docShares,
+ displayName: "QA Doc",
+ getDb: () => db,
+ resolveAccessContext: (ctx) => ({ userEmail: ctx.userEmail }),
+ },
+ {
+ userEmail: viewerEmail,
+ orgId,
+ transaction,
+ },
+ );
+
+ expect(resolved).toEqual({ userEmail: viewerEmail, transaction });
+});
+
async function insertDoc(values: {
id: string;
ownerEmail?: string;
diff --git a/packages/core/src/sharing/access.ts b/packages/core/src/sharing/access.ts
index ad3c8096666..e9d95e3a5e6 100644
--- a/packages/core/src/sharing/access.ts
+++ b/packages/core/src/sharing/access.ts
@@ -14,7 +14,9 @@
*/
import { and, eq, isNull, or, sql, type SQL } from "drizzle-orm";
+import { drizzle as drizzleProxy } from "drizzle-orm/pg-proxy";
+import { withDbExec, type DbExec } from "../db/client.js";
import { evaluateFeatureFlagStrict } from "../feature-flags/store.js";
import { CROSS_APP_ORG_FEDERATION_FLAG } from "../org/feature-flags.js";
import { isMissingOrganizationTableError } from "../org/membership.js";
@@ -63,6 +65,7 @@ export class ForbiddenError extends Error {
}
export interface AccessContext {
+ transaction?: DbExec;
userEmail?: string;
orgId?: string;
authCapability?: string;
@@ -86,7 +89,7 @@ export function resolveRegisteredAccessContext(
): AccessContext {
if (!reg?.resolveAccessContext) return ctx;
const resolved = reg.resolveAccessContext(ctx);
- return ctx.authCapability
+ const preserved = ctx.authCapability
? {
...resolved,
authCapability: ctx.authCapability,
@@ -102,6 +105,9 @@ export function resolveRegisteredAccessContext(
...resolved,
federationMembershipValidated: ctx.federationMembershipValidated,
};
+ return ctx.transaction
+ ? { ...preserved, transaction: ctx.transaction }
+ : preserved;
}
function normalizeEmailForAccess(email: string | undefined): string | null {
@@ -127,6 +133,7 @@ async function isOrgMember(
reg: ShareableResourceRegistration,
memberOrgId: string,
email: string,
+ ctx: AccessContext,
): Promise {
const db = reg.getDb() as any;
const rows = await db
@@ -171,6 +178,7 @@ async function isOrgMember(
userEmail: email,
userKey: email,
orgId: memberOrgId,
+ transaction: ctx.transaction,
}))
) {
return true;
@@ -591,7 +599,11 @@ export async function resolveAccess(
rawCtx: AccessContext = currentAccess(),
options: ResolveAccessOptions = {},
): Promise {
- return resolveAccessImpl(resourceType, resourceId, rawCtx, options);
+ return rawCtx.transaction
+ ? withDbExec(rawCtx.transaction, () =>
+ resolveAccessImpl(resourceType, resourceId, rawCtx, options),
+ )
+ : resolveAccessImpl(resourceType, resourceId, rawCtx, options);
}
/**
@@ -609,7 +621,17 @@ async function resolveAccessImpl(
rawCtx: AccessContext = currentAccess(),
options: ResolveAccessOptions = {},
): Promise {
- const reg = requireShareableResource(resourceType);
+ const registered = requireShareableResource(resourceType);
+ const transaction = rawCtx.transaction;
+ const transactionDb = transaction
+ ? drizzleProxy(async (query, params) => {
+ const result = await transaction.execute({ sql: query, args: params });
+ return { rows: result.rows.map((row) => Object.values(row)) };
+ })
+ : null;
+ const reg = transactionDb
+ ? { ...registered, getDb: () => transactionDb }
+ : registered;
const ctx = resolveRegisteredAccessContext(reg, rawCtx);
const resource = await loadResourceForAccess(reg, resourceId, options);
@@ -650,7 +672,7 @@ async function resolveAccessImpl(
resource.visibility === "org" &&
resource.orgId &&
normalizedUserEmail &&
- (await isOrgMember(reg, resource.orgId, normalizedUserEmail))
+ (await isOrgMember(reg, resource.orgId, normalizedUserEmail, ctx))
) {
const role = await highestShareRole(reg, resourceId, ctx, resource);
return { role: role ?? "viewer", resource };
@@ -693,7 +715,7 @@ async function highestShareRole(
let best: ShareRole | null = null;
if (reg.supportsGroupShares && normalizedUserEmail && resource.orgId) {
- if (await isOrgMember(reg, resource.orgId, normalizedUserEmail)) {
+ if (await isOrgMember(reg, resource.orgId, normalizedUserEmail, ctx)) {
const groupRows = await db
.select({
principalId: reg.sharesTable.principalId,
diff --git a/templates/content/actions/_document-edit-mutation.db.test.ts b/templates/content/actions/_document-edit-mutation.db.test.ts
index fe0f0783bb7..cf13639dc6c 100644
--- a/templates/content/actions/_document-edit-mutation.db.test.ts
+++ b/templates/content/actions/_document-edit-mutation.db.test.ts
@@ -283,6 +283,11 @@ describe("revisioned document edit mutation", () => {
...input,
resolveCreativeContext: async () => {
resolutionCount += 1;
+ const [document] = await getDb()
+ .select()
+ .from(schema.documents)
+ .where(eq(schema.documents.id, DOCUMENT_ID));
+ expect(document.content).toBe("alpha beta");
return undefined;
},
});
@@ -338,4 +343,26 @@ describe("revisioned document edit mutation", () => {
await getDb().select().from(schema.documentEditReceipts),
).toHaveLength(0);
});
+
+ it("rejects a stale base before resolving mutable creative context", async () => {
+ await getDb()
+ .update(schema.documents)
+ .set({ content: "changed outside the edit protocol" })
+ .where(eq(schema.documents.id, DOCUMENT_ID));
+ let resolutionCount = 0;
+ await expect(
+ mutateDocumentBody({
+ documentId: DOCUMENT_ID,
+ baseRevision: documentRevisionToken(0, "alpha beta"),
+ idempotencyKey: "stale-before-context",
+ edits: [{ find: "alpha", replace: "omega" }],
+ resolveCreativeContext: async () => {
+ resolutionCount += 1;
+ throw new Error("mutable context should not be resolved");
+ },
+ ctx,
+ }),
+ ).rejects.toMatchObject({ errorCode: "STALE_BASE_REVISION" });
+ expect(resolutionCount).toBe(0);
+ });
});
diff --git a/templates/content/actions/_document-edit-mutation.ts b/templates/content/actions/_document-edit-mutation.ts
index 0d9db67d3b7..77c9e44e139 100644
--- a/templates/content/actions/_document-edit-mutation.ts
+++ b/templates/content/actions/_document-edit-mutation.ts
@@ -60,6 +60,49 @@ function conflict(
});
}
+function validateDocumentEditSnapshot(
+ document: typeof schema.documents.$inferSelect | undefined,
+ base: { revision: number; contentHash: string },
+ baseRevision: string,
+ edits: DocumentTextEdit[],
+) {
+ if (!document) {
+ throw new ActionContractError("Document not found.", {
+ errorCode: "DOCUMENT_NOT_FOUND",
+ statusCode: 404,
+ });
+ }
+ const beforeContent = document.content ?? "";
+ const beforeHash = documentContentHash(beforeContent);
+ if (
+ document.bodyRevision !== base.revision ||
+ beforeHash !== base.contentHash
+ ) {
+ conflict("STALE_BASE_REVISION", "The document changed after it was read.", {
+ expectedRevision: baseRevision,
+ currentRevision: documentRevisionToken(
+ document.bodyRevision,
+ beforeContent,
+ ),
+ currentBodyRevision: document.bodyRevision,
+ currentContentHash: beforeHash,
+ });
+ }
+ const resolved = resolveDocumentTextEdits(beforeContent, edits);
+ if (!resolved.ok) {
+ conflict(
+ resolved.error.kind === "missing"
+ ? "EDIT_MATCH_MISSING"
+ : resolved.error.kind === "ambiguous"
+ ? "EDIT_MATCH_AMBIGUOUS"
+ : "EDIT_RANGES_OVERLAP",
+ "The complete edit batch could not be resolved against the base document.",
+ { validation: resolved.error },
+ );
+ }
+ return { document, beforeContent, beforeHash, resolved };
+}
+
function callerScope(ctx: ActionRunContext): string {
if (!ctx.userEmail) {
throw new ActionContractError(
@@ -171,75 +214,62 @@ export async function mutateDocumentBody(args: {
creativeContext: args.creativeContextDigest ?? args.creativeContext ?? null,
});
+ const readReplay = async (client: Db) => {
+ const [stored] = await client
+ .select()
+ .from(schema.documentEditReceipts)
+ .where(
+ and(
+ eq(schema.documentEditReceipts.documentId, args.documentId),
+ eq(schema.documentEditReceipts.callerScope, scope),
+ eq(schema.documentEditReceipts.idempotencyKey, args.idempotencyKey),
+ ),
+ );
+ if (stored) {
+ if (stored.payloadDigest !== payloadDigest) {
+ conflict(
+ "IDEMPOTENCY_KEY_REUSED",
+ "This idempotency key was already used for a different document edit.",
+ { idempotencyKey: args.idempotencyKey },
+ );
+ }
+ return replayResult(stored);
+ }
+ return null;
+ };
+
try {
+ const previous = await readReplay(db);
+ if (previous) return previous;
+ const [preflightDocument] = await db
+ .select()
+ .from(schema.documents)
+ .where(eq(schema.documents.id, args.documentId));
+ validateDocumentEditSnapshot(
+ preflightDocument,
+ base,
+ args.baseRevision,
+ normalizedEdits,
+ );
+ const creativeContext = args.resolveCreativeContext
+ ? await args.resolveCreativeContext()
+ : args.creativeContext;
return await db.transaction(async (transaction) => {
const tx = transaction as unknown as Db;
- const [stored] = await tx
- .select()
- .from(schema.documentEditReceipts)
- .where(
- and(
- eq(schema.documentEditReceipts.documentId, args.documentId),
- eq(schema.documentEditReceipts.callerScope, scope),
- eq(schema.documentEditReceipts.idempotencyKey, args.idempotencyKey),
- ),
- );
- if (stored) {
- if (stored.payloadDigest !== payloadDigest) {
- conflict(
- "IDEMPOTENCY_KEY_REUSED",
- "This idempotency key was already used for a different document edit.",
- { idempotencyKey: args.idempotencyKey },
- );
- }
- return replayResult(stored);
- }
+ const concurrent = await readReplay(tx);
+ if (concurrent) return concurrent;
const [document] = await tx
.select()
.from(schema.documents)
.where(eq(schema.documents.id, args.documentId));
- if (!document) {
- throw new ActionContractError("Document not found.", {
- errorCode: "DOCUMENT_NOT_FOUND",
- statusCode: 404,
- });
- }
- const beforeContent = document.content ?? "";
- const beforeHash = documentContentHash(beforeContent);
- if (
- document.bodyRevision !== base.revision ||
- beforeHash !== base.contentHash
- ) {
- conflict(
- "STALE_BASE_REVISION",
- "The document changed after it was read.",
- {
- expectedRevision: args.baseRevision,
- currentRevision: documentRevisionToken(
- document.bodyRevision,
- beforeContent,
- ),
- currentBodyRevision: document.bodyRevision,
- currentContentHash: beforeHash,
- },
- );
- }
- const resolved = resolveDocumentTextEdits(beforeContent, normalizedEdits);
- if (!resolved.ok) {
- conflict(
- resolved.error.kind === "missing"
- ? "EDIT_MATCH_MISSING"
- : resolved.error.kind === "ambiguous"
- ? "EDIT_MATCH_AMBIGUOUS"
- : "EDIT_RANGES_OVERLAP",
- "The complete edit batch could not be resolved against the base document.",
- { validation: resolved.error },
- );
- }
- const creativeContext = args.resolveCreativeContext
- ? await args.resolveCreativeContext()
- : args.creativeContext;
+ const validated = validateDocumentEditSnapshot(
+ document,
+ base,
+ args.baseRevision,
+ normalizedEdits,
+ );
+ const { beforeContent, beforeHash, resolved } = validated;
const changed = resolved.content !== beforeContent;
const afterRevision = changed
@@ -385,24 +415,8 @@ export async function mutateDocumentBody(args: {
// Concurrent duplicate deliveries may both miss the receipt before one
// commits. Re-read after rollback so the loser returns the winner's durable
// outcome instead of surfacing a false stale/unique-key failure.
- const [stored] = await db
- .select()
- .from(schema.documentEditReceipts)
- .where(
- and(
- eq(schema.documentEditReceipts.documentId, args.documentId),
- eq(schema.documentEditReceipts.callerScope, scope),
- eq(schema.documentEditReceipts.idempotencyKey, args.idempotencyKey),
- ),
- );
- if (!stored) throw error;
- if (stored.payloadDigest !== payloadDigest) {
- conflict(
- "IDEMPOTENCY_KEY_REUSED",
- "This idempotency key was already used for a different document edit.",
- { idempotencyKey: args.idempotencyKey },
- );
- }
- return replayResult(stored);
+ const replay = await readReplay(db);
+ if (!replay) throw error;
+ return replay;
}
}
diff --git a/templates/content/actions/add-comment.test.ts b/templates/content/actions/add-comment.test.ts
index 84ee7835286..b77357a6c59 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,8 @@ vi.mock("@agent-native/core/sharing", () => ({
assertAccess: (...args: unknown[]) => mockAssertAccess(...args),
}));
vi.mock("@agent-native/core/server", () => ({
- getRequestRunContext: () => ({ caller: "mcp" }),
+ getRequestRunContext: () =>
+ state.agent ? { runId: "agent-run-1" } : { browserTabId: "human-tab-1" },
getRequestUserEmail: () => "author@example.com",
getRequestUserName: () => "Authenticated Profile Name",
}));
@@ -44,6 +48,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 +59,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 +122,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 +156,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 when request context contains only a browser tab", 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..56a30137bec 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()?.runId ||
+ 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..0f07996f86f
--- /dev/null
+++ b/templates/content/actions/comment-ai-flow.test.ts
@@ -0,0 +1,497 @@
+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(),
+ listSuggestions: 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(
+ "@agent-native/core/review/suggestions/actions/list-resource-suggestions",
+ () => ({
+ default: { run: (...args: unknown[]) => mocks.listSuggestions(...args) },
+ }),
+);
+
+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 getContext from "./get-comment-ai-context.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();
+ mocks.listSuggestions.mockResolvedValue({ suggestions: [] });
+ 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,
+ },
+ });
+ });
+});
+
+it("returns current running context when retrying an incomplete operation", async () => {
+ state.request.status = "needs-review";
+ state.request.error = "Previous run ended";
+ state.request.snapshotJson = "[]";
+ state.source.root = { quotedText: null };
+ const result = await run(getContext, {});
+ expect(result).toMatchObject({
+ request: { status: "running", error: null },
+ operationCompleted: false,
+ nextAction: "reply-to-comment-ai-request",
+ });
+});
+
+it("reports prior proposal decisions without treating them as the current result", async () => {
+ state.request.intent = "suggest";
+ state.request.snapshotJson = "[]";
+ state.source.root = { quotedText: null };
+ const prior = (
+ id: string,
+ status: string,
+ sourceThreadId = "thread-1",
+ adapterKind = "content-document",
+ commentAiRequestId = "prior-request",
+ ) => ({
+ id,
+ status,
+ adapterKind,
+ summary: "Earlier proposal",
+ metadata: { sourceThreadId, commentAiRequestId },
+ });
+ mocks.listSuggestions.mockResolvedValue({
+ suggestions: [
+ ...["pending", "accepted", "rejected", "stale", "superseded"].map(
+ (status) => prior(status, status),
+ ),
+ prior("other-thread", "pending", "thread-2"),
+ prior("other-adapter", "pending", "thread-1", "another-adapter"),
+ prior(
+ "current",
+ "pending",
+ "thread-1",
+ "content-document",
+ state.request.id,
+ ),
+ ],
+ });
+ const result = (await run(getContext, {})) as any;
+ expect(result.operationCompleted).toBe(false);
+ expect(result.nextAction).toBe("create-comment-ai-suggestion");
+ expect(result.priorSuggestions.map((s: any) => s.status)).toEqual([
+ "pending",
+ "accepted",
+ "rejected",
+ "stale",
+ "superseded",
+ ]);
+ expect(mocks.listSuggestions).toHaveBeenCalledWith(
+ { resourceType: "document", resourceId: "page-1" },
+ ctx,
+ );
+});
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..8982a186cfe
--- /dev/null
+++ b/templates/content/actions/create-comment-ai-suggestion.ts
@@ -0,0 +1,132 @@
+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 type { CommentAiRequest } from "../shared/comment-ai.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"),
+});
+
+function suggestionResult(request: CommentAiRequest) {
+ if (!request.result?.suggestionId)
+ throw new Error("The completed proposal has no suggestion result");
+ return {
+ ...request,
+ urlPath: `/page/${encodeURIComponent(request.documentId)}?suggestion=${encodeURIComponent(request.result.suggestionId)}`,
+ };
+}
+
+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 suggestionResult(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 suggestionResult(
+ 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..0afb210d0cc
--- /dev/null
+++ b/templates/content/actions/get-comment-ai-context.ts
@@ -0,0 +1,90 @@
+import { defineAction } from "@agent-native/core/action";
+import listSuggestions from "@agent-native/core/review/suggestions/actions/list-resource-suggestions";
+import { z } from "zod";
+
+import {
+ assertCommentAiSourceUnchanged,
+ requireCommentAiRequest,
+ serializeCommentAiRequest,
+ updateCommentAiRequest,
+} from "../server/lib/comment-ai.js";
+import { CONTENT_DOCUMENT_SUGGESTION_ADAPTER } from "../server/lib/suggested-edits.js";
+import { documentRevisionToken } from "./_document-edit-mutation.js";
+
+export default defineAction({
+ description:
+ "Read the current Page body and exact comment conversation before the dedicated operation. Use the current content for Page facts; earlier replies may describe an older revision. The submitted conversation records the request context. An existing result is durable; do not duplicate it.",
+ schema: z.object({}),
+ run: async (_args, ctx) => {
+ const request = await requireCommentAiRequest();
+ const receipt = serializeCommentAiRequest(request);
+ if (["replied", "suggested", "resolved"].includes(request.status))
+ return { request: receipt, operationCompleted: true, nextAction: null };
+ 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.",
+ );
+ const current = await updateCommentAiRequest(request, {
+ status: "running",
+ });
+ const { suggestions } = await listSuggestions.run(
+ { resourceType: "document", resourceId: request.documentId },
+ ctx,
+ );
+ return {
+ request: current,
+ operationCompleted: false,
+ nextAction: {
+ reply: "reply-to-comment-ai-request",
+ suggest: "create-comment-ai-suggestion",
+ "apply-resolve": "apply-comment-ai-request",
+ }[request.intent],
+ 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),
+ priorSuggestions: suggestions
+ .filter(
+ (suggestion) =>
+ suggestion.adapterKind === CONTENT_DOCUMENT_SUGGESTION_ADAPTER &&
+ suggestion.metadata?.sourceThreadId === request.threadId &&
+ suggestion.metadata?.commentAiRequestId !== request.id,
+ )
+ .map((suggestion) => ({
+ id: suggestion.id,
+ status: suggestion.status,
+ summary: suggestion.summary,
+ urlPath: `/page/${encodeURIComponent(request.documentId)}?suggestion=${encodeURIComponent(suggestion.id)}`,
+ commentAiRequestId: suggestion.metadata?.commentAiRequestId,
+ })),
+ 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..a6145ba64e7 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";
@@ -96,6 +101,22 @@ function renderCommentBody(content: string, mentions: CommentMention[]) {
content={content}
inline
protectedSpans={commentMentionSpans(mentions)}
+ renderLink={(href, children, className) =>
+ href.startsWith("/page/") ? (
+
+ {children}
+
+ ) : (
+
+ {children}
+
+ )
+ }
/>
);
}
@@ -150,6 +171,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 +421,8 @@ interface CommentsSidebarProps {
suggestions?: ResourceSuggestion[];
canDecideSuggestions?: boolean;
decidingSuggestion?: boolean;
+ canSuggest?: boolean;
+ commentAi?: CommentAiController;
onDecideSuggestion?: (
suggestion: ResourceSuggestion,
decision: SuggestionDecision,
@@ -420,6 +456,8 @@ export function CommentsSidebar({
suggestions = [],
canDecideSuggestions = false,
decidingSuggestion = false,
+ canSuggest = false,
+ commentAi,
onDecideSuggestion,
visibleThreadId,
presentation = "inline",
@@ -529,8 +567,9 @@ export function CommentsSidebar({
);
}, [suggestions, threads]);
const historySuggestions = useMemo(() => {
- if (historyKind === "comments") return [];
return suggestions.filter((suggestion) => {
+ if (suggestion.id === activeSuggestionId) return true;
+ if (historyKind === "comments") return false;
if (historyStatus === "open" && suggestion.status !== "pending") {
return false;
}
@@ -545,7 +584,13 @@ export function CommentsSidebar({
}
return !historyAuthor || suggestion.authorEmail === historyAuthor;
});
- }, [historyAuthor, historyKind, historyStatus, suggestions]);
+ }, [
+ activeSuggestionId,
+ historyAuthor,
+ historyKind,
+ historyStatus,
+ suggestions,
+ ]);
const historyThreads = useMemo(() => {
if (historyKind === "suggestions") return [];
return threads.filter((thread) => {
@@ -651,17 +696,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 +908,10 @@ export function CommentsSidebar({
const after = operation?.after as
| { changedText?: string }
| undefined;
+ const sourceUrl =
+ typeof suggestion.metadata?.sourceUrl === "string"
+ ? suggestion.metadata.sourceUrl
+ : null;
return (
{operation?.kind.split("_").join(" ")} ·{" "}
- {suggestion.authorEmail ?? suggestion.actorKind}
+ {suggestion.actorKind === "agent"
+ ? t("comments.aiBadge")
+ : (suggestion.authorEmail ?? suggestion.actorKind)}
{suggestion.status}
@@ -896,6 +954,15 @@ export function CommentsSidebar({
{renderSuggestionText(after.changedText)}
) : null}
+ {sourceUrl ? (
+ event.stopPropagation()}
+ >
+ {t("comments.sourceComment")}
+
+ ) : null}
{canDecideSuggestions && suggestion.status === "pending" ? (
);
@@ -1129,6 +1197,14 @@ export function CommentsSidebar({
members={members}
canComment={canComment}
canResolve={canResolve}
+ canSuggest={canSuggest}
+ commentAiRequest={latestCommentAiRequest(
+ commentAi?.requests ?? [],
+ thread.threadId,
+ )}
+ commentAiStarting={
+ commentAi?.startingThreadIds.has(thread.threadId) ?? false
+ }
onHoverChange={(hovered) =>
onHoveredThreadChange?.(hovered ? thread.threadId : null)
}
@@ -1141,7 +1217,9 @@ export function CommentsSidebar({
onHeightChange={handleThreadCardHeightChange}
onSubmitReply={() => handleReply(thread.threadId)}
onResolve={() => handleResolve(thread)}
- onSendToAI={() => handleSendToAI(thread)}
+ onStartCommentAi={(intent, requestId) =>
+ handleStartCommentAi(thread, intent, requestId)
+ }
t={t}
/>
) : (
@@ -1289,11 +1367,21 @@ export function CommentsSidebar({
members={members}
canComment={canComment}
canResolve={canResolve}
+ canSuggest={canSuggest}
+ commentAiRequest={latestCommentAiRequest(
+ commentAi?.requests ?? [],
+ thread.threadId,
+ )}
+ commentAiStarting={
+ commentAi?.startingThreadIds.has(thread.threadId) ?? false
+ }
onSubmitReply={() => handleReply(thread.threadId)}
onResolve={() =>
thread.resolved ? handleReopen(thread) : handleResolve(thread)
}
- onSendToAI={() => handleSendToAI(thread)}
+ onStartCommentAi={(intent, requestId) =>
+ handleStartCommentAi(thread, intent, requestId)
+ }
t={t}
/>
@@ -1329,6 +1417,7 @@ function HistoryThreadView({
name={first.author_name ?? first.author_email}
className="size-5 shrink-0"
/>
+
{renderCommentBody(first.content, first.mentions)}
@@ -1363,7 +1452,10 @@ function ThreadView({
onResolve,
canComment,
canResolve,
- onSendToAI,
+ canSuggest,
+ commentAiRequest,
+ commentAiStarting,
+ onStartCommentAi,
t,
}: {
thread: CommentThread;
@@ -1386,7 +1478,13 @@ function ThreadView({
onResolve: () => void;
canComment: boolean;
canResolve: boolean;
- onSendToAI: () => void;
+ canSuggest: boolean;
+ commentAiRequest?: CommentAiRequest;
+ commentAiStarting: boolean;
+ onStartCommentAi: (
+ intent: CommentAiIntent,
+ requestId?: string,
+ ) => Promise
;
t: ReturnType;
}) {
const replyInputRef = useRef(null);
@@ -1442,22 +1540,15 @@ function ThreadView({
{/* Hover actions — top right, Notion style pill */}
-
-
-
-
- {t("comments.askAi")}
-
+
{canResolve ? (
@@ -1694,7 +1785,7 @@ function CommentEntry({
if (
editing ||
(event.target as HTMLElement).closest(
- "button, textarea, [role=menuitem]",
+ "a, button, textarea, [role=menuitem]",
)
)
event.stopPropagation();
@@ -1708,6 +1799,7 @@ function CommentEntry({
{comment.author_name ?? comment.author_email.split("@")[0]}
+
{formatDate(comment.created_at)}
diff --git a/templates/content/app/components/editor/DocumentEditor.tsx b/templates/content/app/components/editor/DocumentEditor.tsx
index 1f32dcdde4c..1eec0715c76 100644
--- a/templates/content/app/components/editor/DocumentEditor.tsx
+++ b/templates/content/app/components/editor/DocumentEditor.tsx
@@ -112,6 +112,7 @@ import {
newDocumentPageChoiceIsDisabled,
} from "./body-hydration";
import { BuilderBodySyncingNotice } from "./BuilderBodySyncingNotice";
+import { useCommentAiRequests } from "./comment-ai";
import type { CommentTextAnchor } from "./comment-anchors";
import {
CommentDraftProvider,
@@ -897,6 +898,7 @@ function DocumentEditorBody({
) &&
!document.database &&
!document.source?.mode;
+ const commentAi = useCommentAiRequests(documentId, { enabled: canComment });
const canDelete =
!isLocalFileDocument &&
!document.database?.systemRole &&
@@ -1031,9 +1033,22 @@ function DocumentEditorBody({
t,
]);
+ const handledSuggestionDeepLinkRef = useRef(null);
useEffect(() => {
const suggestionId = new URLSearchParams(location.search).get("suggestion");
- if (!suggestionId || !suggestionsQuery.data) return;
+ if (!suggestionId) {
+ handledSuggestionDeepLinkRef.current = null;
+ return;
+ }
+ const deepLinkKey = `${documentId}:${suggestionId}`;
+ if (
+ handledSuggestionDeepLinkRef.current === deepLinkKey ||
+ !suggestionsQuery.data?.suggestions.some(
+ (suggestion) => suggestion.id === suggestionId,
+ )
+ )
+ return;
+ setSelectedSuggestionId(suggestionId);
if (utilityPanel !== "comments" || !commentsBrowseOpen) {
setUtilityPanel("comments");
setCommentsBrowseOpen(true);
@@ -1042,11 +1057,16 @@ function DocumentEditorBody({
const target = globalThis.document.querySelector(
`[data-suggestion-id="${CSS.escape(suggestionId)}"]`,
);
- target?.scrollIntoView({ block: "nearest" });
- target?.focus();
+ if (!target) return;
+ handledSuggestionDeepLinkRef.current = deepLinkKey;
+ target.scrollIntoView({ block: "nearest" });
+ target.focus();
}, [
commentsBrowseOpen,
+ commentsHistoryRailMounted,
+ documentId,
location.search,
+ selectedSuggestionId,
suggestionsQuery.data,
utilityPanel,
]);
@@ -2593,6 +2613,8 @@ function DocumentEditorBody({
});
}, []);
+ const handledCommentDeepLinkRef = useRef(null);
+
const handleUtilityPanelChange = useCallback(
(nextPanel: DocumentUtilityPanel) => {
setUtilityPanel(nextPanel);
@@ -2615,6 +2637,23 @@ function DocumentEditorBody({
setSelectedSuggestionId(null);
}, [clearCommentFocus, documentId]);
+ useEffect(() => {
+ const threadId = new URLSearchParams(location.search).get("comment");
+ if (!threadId) {
+ handledCommentDeepLinkRef.current = null;
+ return;
+ }
+ const deepLinkKey = `${documentId}:${threadId}`;
+ if (
+ handledCommentDeepLinkRef.current === deepLinkKey ||
+ !threads?.some((thread) => thread.threadId === threadId)
+ ) {
+ return;
+ }
+ handledCommentDeepLinkRef.current = deepLinkKey;
+ activateCommentThread(threadId, true);
+ }, [activateCommentThread, documentId, location.search, threads]);
+
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") dismissCommentFocus();
@@ -2865,6 +2904,8 @@ function DocumentEditorBody({
suggestions={suggestionsQuery.data?.suggestions ?? []}
canDecideSuggestions={canEdit}
decidingSuggestion={decideSuggestion.isPending}
+ canSuggest={canSuggest}
+ commentAi={commentAi}
onDecideSuggestion={(suggestion, decision) =>
decideSuggestion.mutate({
id: suggestion.id,
diff --git a/templates/content/app/components/editor/comment-ai.test.tsx b/templates/content/app/components/editor/comment-ai.test.tsx
new file mode 100644
index 00000000000..b8fe471096c
--- /dev/null
+++ b/templates/content/app/components/editor/comment-ai.test.tsx
@@ -0,0 +1,272 @@
+// @vitest-environment happy-dom
+
+import type { CommentAiRequest } from "@shared/comment-ai";
+import { act, createElement } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { MemoryRouter } from "react-router";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import {
+ commentAiRequestsRefetchInterval,
+ CommentAiThreadActions,
+ type CommentAiController,
+ useCommentAiRequests,
+} from "./comment-ai";
+
+const api = vi.hoisted(() => ({
+ callAction: vi.fn(),
+ refetch: vi.fn(),
+ sendToAgentChat: vi.fn(),
+ requests: [] as CommentAiRequest[],
+}));
+
+vi.mock("@agent-native/core/client/hooks", () => ({
+ callAction: (...args: unknown[]) => api.callAction(...args),
+ useActionQuery: () => ({
+ data: { requests: api.requests },
+ refetch: api.refetch,
+ }),
+}));
+vi.mock("@agent-native/core/client/agent-chat", () => ({
+ sendToAgentChat: (...args: unknown[]) => api.sendToAgentChat(...args),
+}));
+vi.mock("@agent-native/core/client/i18n", () => ({
+ useT: () => (key: string) => key,
+}));
+
+function request(overrides: Partial = {}): CommentAiRequest {
+ return {
+ requestId: "request-1",
+ documentId: "document-1",
+ threadId: "thread-1",
+ rootCommentId: "comment-1",
+ intent: "suggest",
+ status: "failed",
+ runId: null,
+ agentThreadId: null,
+ result: null,
+ error: "The request failed",
+ createdAt: "2026-09-08T12:00:00.000Z",
+ updatedAt: "2026-09-08T12:00:00.000Z",
+ ...overrides,
+ };
+}
+
+describe("comment AI controls", () => {
+ let container: HTMLDivElement;
+ let root: Root;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.append(container);
+ root = createRoot(container);
+ api.requests = [];
+ api.refetch.mockResolvedValue(undefined);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ vi.clearAllMocks();
+ });
+
+ function renderControls(
+ props: Partial[0]> = {},
+ ) {
+ const onStart = vi.fn().mockResolvedValue(undefined);
+ act(() =>
+ root.render(
+ createElement(
+ MemoryRouter,
+ null,
+ createElement(
+ "div",
+ null,
+ createElement("textarea", { defaultValue: "unfinished reply" }),
+ createElement(CommentAiThreadActions, {
+ "aria-label": "comments.askAi",
+ starting: false,
+ canSuggest: true,
+ canReply: true,
+ canApply: true,
+ onStart,
+ ...props,
+ }),
+ ),
+ ),
+ ),
+ );
+ return onStart;
+ }
+
+ async function openMenu(trigger: HTMLButtonElement) {
+ await act(async () => {
+ trigger.dispatchEvent(
+ new PointerEvent("pointerdown", {
+ bubbles: true,
+ button: 0,
+ ctrlKey: false,
+ }),
+ );
+ });
+ }
+
+ it("opens and dismisses the ordered menu without dispatching", async () => {
+ const onStart = renderControls();
+ const trigger = container.querySelector(
+ '[aria-label="comments.askAi"]',
+ )!;
+ await openMenu(trigger);
+ const items = [
+ ...document.querySelectorAll("[role=menuitem]"),
+ ];
+ expect(items.map((item) => item.textContent)).toEqual([
+ "comments.aiSuggestChanges",
+ "comments.aiReplyInThread",
+ "comments.aiApplyAndResolve",
+ ]);
+ await act(async () =>
+ document.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
+ ),
+ );
+ expect(onStart).not.toHaveBeenCalled();
+ expect(container.querySelector("textarea")?.value).toBe("unfinished reply");
+ });
+
+ it("supports keyboard selection and labels unavailable suggestions", async () => {
+ const onStart = renderControls();
+ const trigger = container.querySelector(
+ '[aria-label="comments.askAi"]',
+ )!;
+ await act(async () =>
+ trigger.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }),
+ ),
+ );
+ const first = document.querySelector("[role=menuitem]")!;
+ await act(async () =>
+ first.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
+ ),
+ );
+ expect(onStart).toHaveBeenCalledWith("suggest", undefined);
+
+ act(() => root.unmount());
+ root = createRoot(container);
+ renderControls({ canSuggest: false });
+ await openMenu(
+ container.querySelector(
+ '[aria-label="comments.askAi"]',
+ )!,
+ );
+ expect(document.body.textContent).toContain("comments.aiUnavailable");
+ });
+
+ it("shows actionable errors and retries with the same request id", async () => {
+ const failed = request();
+ const onStart = renderControls({ request: failed });
+ expect(document.querySelector('[role="alert"]')?.textContent).toContain(
+ "comments.aiFailed",
+ );
+ expect(
+ document.querySelector('[role="alert"]')?.getAttribute("title"),
+ ).toBe(failed.error);
+ await act(async () =>
+ [...document.querySelectorAll("button")]
+ .find((button) => button.textContent === "comments.retry")!
+ .click(),
+ );
+ expect(onStart).toHaveBeenCalledWith(failed.intent, failed.requestId);
+ });
+
+ it("prevents duplicate starts and sends localized text with hidden scoped context", async () => {
+ const requestId = "00000000-0000-4000-8000-000000000001";
+ let controller: CommentAiController;
+ function Probe() {
+ controller = useCommentAiRequests("document-1", { enabled: true });
+ return null;
+ }
+ api.callAction.mockResolvedValue({
+ ...request({ status: "queued", error: null }),
+ dispatch: true,
+ prompt: "Handle the source comment",
+ context: "Hidden comment AI instructions",
+ actionScope: { kind: "content-comment-ai", requestId },
+ });
+ vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(requestId);
+ act(() => root.render(createElement(Probe)));
+
+ const input = {
+ threadId: "thread-1",
+ rootCommentId: "comment-1",
+ intent: "suggest" as const,
+ };
+ await act(async () => {
+ await Promise.all([controller!.start(input), controller!.start(input)]);
+ });
+
+ expect(api.callAction).toHaveBeenCalledOnce();
+ expect(api.callAction).toHaveBeenCalledWith("start-comment-ai-request", {
+ documentId: "document-1",
+ threadId: "thread-1",
+ rootCommentId: "comment-1",
+ intent: "suggest",
+ requestId,
+ });
+ expect(api.sendToAgentChat).toHaveBeenCalledWith({
+ message: "comments.aiPromptSuggest",
+ context: "Hidden comment AI instructions",
+ submit: true,
+ openSidebar: true,
+ actionScope: { kind: "content-comment-ai", requestId },
+ });
+ });
+
+ it("does not dispatch an active request returned by a racing start", async () => {
+ let controller: CommentAiController;
+ function Probe() {
+ controller = useCommentAiRequests("document-1", { enabled: true });
+ return null;
+ }
+ api.callAction.mockResolvedValue({
+ ...request({ status: "running", error: null }),
+ dispatch: false,
+ prompt: "Reply in thread for this comment.",
+ context: "Hidden comment AI instructions",
+ actionScope: { kind: "content-comment-ai", requestId: "request-1" },
+ });
+ act(() => root.render(createElement(Probe)));
+
+ await act(async () => {
+ await controller!.start({
+ threadId: "thread-1",
+ rootCommentId: "comment-1",
+ intent: "reply",
+ });
+ });
+
+ expect(api.callAction).toHaveBeenCalledOnce();
+ expect(api.sendToAgentChat).not.toHaveBeenCalled();
+ expect(api.refetch).toHaveBeenCalledOnce();
+ });
+
+ it("polls only while a saved request is queued or running", () => {
+ expect(
+ commentAiRequestsRefetchInterval({
+ requests: [request({ status: "queued" })],
+ }),
+ ).toBe(2_000);
+ expect(
+ commentAiRequestsRefetchInterval({
+ requests: [request({ status: "running" })],
+ }),
+ ).toBe(2_000);
+ expect(
+ commentAiRequestsRefetchInterval({
+ requests: [request({ status: "replied" })],
+ }),
+ ).toBe(false);
+ expect(commentAiRequestsRefetchInterval(undefined)).toBe(false);
+ });
+});
diff --git a/templates/content/app/components/editor/comment-ai.tsx b/templates/content/app/components/editor/comment-ai.tsx
new file mode 100644
index 00000000000..3d25773e8e8
--- /dev/null
+++ b/templates/content/app/components/editor/comment-ai.tsx
@@ -0,0 +1,315 @@
+import { sendToAgentChat } from "@agent-native/core/client/agent-chat";
+import { callAction, useActionQuery } from "@agent-native/core/client/hooks";
+import { useT } from "@agent-native/core/client/i18n";
+import type {
+ CommentAiIntent,
+ CommentAiRequest,
+ StartCommentAiResult,
+} from "@shared/comment-ai";
+import { IconSparkles } from "@tabler/icons-react";
+import { useCallback, useMemo, useRef, useState } from "react";
+import { Link } from "react-router";
+
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+const ACTIVE_STATUSES = new Set([
+ "queued",
+ "running",
+]);
+const ACTIVE_REQUEST_REFETCH_INTERVAL_MS = 2_000;
+
+export function commentAiRequestsRefetchInterval(
+ data: unknown,
+): number | false {
+ if (!data || typeof data !== "object" || !("requests" in data)) return false;
+ const requests = (data as { requests?: unknown }).requests;
+ return Array.isArray(requests) &&
+ requests.some(
+ (request) =>
+ request &&
+ typeof request === "object" &&
+ "status" in request &&
+ (request.status === "queued" || request.status === "running"),
+ )
+ ? ACTIVE_REQUEST_REFETCH_INTERVAL_MS
+ : false;
+}
+
+export interface CommentAiController {
+ requests: CommentAiRequest[];
+ startingThreadIds: ReadonlySet;
+ start(input: {
+ threadId: string;
+ rootCommentId: string;
+ intent: CommentAiIntent;
+ requestId?: string;
+ }): Promise;
+}
+
+export function latestCommentAiRequest(
+ requests: readonly CommentAiRequest[],
+ threadId: string,
+): CommentAiRequest | undefined {
+ return requests
+ .filter((request) => request.threadId === threadId)
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0];
+}
+
+export function useCommentAiRequests(
+ documentId: string,
+ options: { enabled: boolean },
+): CommentAiController {
+ const t = useT();
+ const query = useActionQuery<{ requests: CommentAiRequest[] }>(
+ "list-comment-ai-requests",
+ { documentId },
+ {
+ enabled: options.enabled,
+ refetchInterval: (state) =>
+ commentAiRequestsRefetchInterval(state.state.data),
+ },
+ );
+ const requests = query.data?.requests ?? [];
+ const requestsRef = useRef(requests);
+ requestsRef.current = requests;
+ const startingRef = useRef(new Set());
+ const [startingThreadIds, setStartingThreadIds] = useState<
+ ReadonlySet
+ >(() => new Set());
+
+ const start = useCallback(
+ async ({ threadId, rootCommentId, intent, requestId: retryRequestId }) => {
+ const active = requestsRef.current.some(
+ (request) =>
+ request.threadId === threadId && ACTIVE_STATUSES.has(request.status),
+ );
+ if (active || startingRef.current.has(threadId)) return;
+
+ const requestId = retryRequestId ?? globalThis.crypto.randomUUID();
+ startingRef.current.add(threadId);
+ setStartingThreadIds(new Set(startingRef.current));
+ try {
+ const started = await callAction(
+ "start-comment-ai-request",
+ {
+ documentId,
+ threadId,
+ rootCommentId,
+ intent,
+ requestId,
+ },
+ );
+ const message = {
+ message: t(
+ intent === "suggest"
+ ? "comments.aiPromptSuggest"
+ : intent === "reply"
+ ? "comments.aiPromptReply"
+ : "comments.aiPromptApplyResolve",
+ ),
+ context: started.context,
+ submit: true,
+ openSidebar: true,
+ actionScope: started.actionScope,
+ };
+ if (started.dispatch) sendToAgentChat(message);
+ await query.refetch();
+ } finally {
+ startingRef.current.delete(threadId);
+ setStartingThreadIds(new Set(startingRef.current));
+ }
+ },
+ [documentId, query, t],
+ );
+
+ return useMemo(
+ () => ({ requests, startingThreadIds, start }),
+ [requests, start, startingThreadIds],
+ );
+}
+
+export function CommentAiThreadActions({
+ "aria-label": ariaLabel,
+ request,
+ starting,
+ canSuggest,
+ canReply,
+ canApply,
+ onStart,
+}: {
+ "aria-label": string;
+ request?: CommentAiRequest;
+ starting: boolean;
+ canSuggest: boolean;
+ canReply: boolean;
+ canApply: boolean;
+ onStart: (intent: CommentAiIntent, requestId?: string) => Promise;
+}) {
+ const t = useT();
+ const active = starting || (request && ACTIVE_STATUSES.has(request.status));
+ const start = (intent: CommentAiIntent, requestId?: string) => {
+ if (active) return;
+ void onStart(intent, requestId);
+ };
+
+ return (
+ <>
+
+
+
+
+ event.stopPropagation()}
+ onClick={(event) => event.stopPropagation()}
+ >
+
+ start("suggest")}
+ >
+ {t("comments.aiSuggestChanges")}
+ {!canSuggest ? (
+
+ {t("comments.aiUnavailable")}
+
+ ) : null}
+
+ start("reply")}
+ >
+ {t("comments.aiReplyInThread")}
+
+
+ {canApply ? (
+ <>
+
+
+ start("apply-resolve")}
+ >
+ {t("comments.aiApplyAndResolve")}
+
+
+ >
+ ) : null}
+
+
+ {request ? (
+ onStart(request.intent, request.requestId)}
+ />
+ ) : null}
+ >
+ );
+}
+
+function CommentAiRequestStatus({
+ request,
+ starting,
+ onRetry,
+}: {
+ request: CommentAiRequest;
+ starting: boolean;
+ onRetry: () => Promise;
+}) {
+ const t = useT();
+ if (request.status === "failed" || request.status === "needs-review") {
+ const label =
+ request.status === "failed"
+ ? t("comments.aiFailed")
+ : t("comments.aiNeedsReview");
+ const suggestionId = request.result?.suggestionId;
+ return (
+
+ {suggestionId ? (
+ event.stopPropagation()}
+ >
+ {label}
+
+ ) : (
+ {label}
+ )}
+
+
+ );
+ }
+
+ const label =
+ request.status === "queued" || request.status === "running"
+ ? t("comments.aiWorking")
+ : request.status === "replied"
+ ? t("comments.aiReplied")
+ : request.status === "suggested"
+ ? t("comments.aiSuggestionReady")
+ : request.status === "resolved"
+ ? t("comments.aiChangesApplied")
+ : t("comments.aiNeedsReview");
+ const suggestionId = request.result?.suggestionId;
+ return (
+
+ {suggestionId ? (
+ event.stopPropagation()}
+ >
+ {label}
+
+ ) : (
+ label
+ )}
+
+ );
+}
diff --git a/templates/content/app/hooks/content-action-refresh.ts b/templates/content/app/hooks/content-action-refresh.ts
index 1b6cf9186c2..2182f46dde9 100644
--- a/templates/content/app/hooks/content-action-refresh.ts
+++ b/templates/content/app/hooks/content-action-refresh.ts
@@ -53,21 +53,67 @@ const CONTENT_MUTATIONS = new Set([
function queryTargetsDocument(query: ActionQuery, documentId: string): boolean {
if (query.queryKey[0] !== "action") return false;
+ const actionName = query.queryKey[1];
+ const args = query.queryKey[2];
+ if (!args || typeof args !== "object") return false;
+ if (actionName === "get-document") {
+ return "id" in args && args.id === documentId;
+ }
if (
- query.queryKey[1] !== "get-document" &&
- query.queryKey[1] !== "list-comments"
+ actionName === "list-comments" ||
+ actionName === "list-comment-ai-requests"
) {
- return false;
+ return "documentId" in args && args.documentId === documentId;
}
- const args = query.queryKey[2];
return (
- !!args &&
- typeof args === "object" &&
- (("id" in args && args.id === documentId) ||
- ("documentId" in args && args.documentId === documentId))
+ actionName === "list-resource-suggestions" &&
+ "resourceType" in args &&
+ args.resourceType === "document" &&
+ "resourceId" in args &&
+ args.resourceId === documentId
);
}
+function queryActionName(query: ActionQuery): string | undefined {
+ return query.queryKey[0] === "action" && typeof query.queryKey[1] === "string"
+ ? query.queryKey[1]
+ : undefined;
+}
+
+function eventRefreshesQuery(eventKey: string, queryAction: string): boolean {
+ if (CONTENT_MUTATIONS.has(eventKey)) {
+ return queryAction === "get-document" || queryAction === "list-comments";
+ }
+ if (eventKey === "start-comment-ai-request") {
+ return queryAction === "list-comment-ai-requests";
+ }
+ if (
+ eventKey === "reply-to-comment-ai-request" ||
+ eventKey === "create-comment-ai-suggestion"
+ ) {
+ return (
+ queryAction === "list-comments" ||
+ queryAction === "list-comment-ai-requests" ||
+ queryAction === "list-resource-suggestions"
+ );
+ }
+ if (eventKey === "apply-comment-ai-request") {
+ return (
+ queryAction === "get-document" ||
+ queryAction === "list-comments" ||
+ queryAction === "list-comment-ai-requests" ||
+ queryAction === "list-resource-suggestions"
+ );
+ }
+ if (eventKey === "decide-resource-suggestion") {
+ return (
+ queryAction === "get-document" ||
+ queryAction === "list-resource-suggestions"
+ );
+ }
+ return false;
+}
+
export function contentDocumentIdFromPathname(
pathname: string,
): string | undefined {
@@ -83,11 +129,13 @@ export function contentActionInvalidatePredicate(
if (documentId === undefined || !queryTargetsDocument(query, documentId)) {
return false;
}
+ const actionName = queryActionName(query);
+ if (!actionName) return false;
return events.some(
(event) =>
event.source === "action" &&
typeof event.key === "string" &&
- CONTENT_MUTATIONS.has(event.key),
+ eventRefreshesQuery(event.key, actionName),
);
};
}
diff --git a/templates/content/app/hooks/use-comments.ts b/templates/content/app/hooks/use-comments.ts
index ce8952601cf..bb52154e8cd 100644
--- a/templates/content/app/hooks/use-comments.ts
+++ b/templates/content/app/hooks/use-comments.ts
@@ -30,6 +30,7 @@ export interface Comment {
mentions: CommentMention[];
author_email: string;
author_name: string | null;
+ actorKind?: "human" | "agent" | null;
resolved: number;
created_at: string;
updated_at: string;
@@ -369,7 +370,15 @@ function groupCommentThreads(data: unknown): CommentThread[] {
data && typeof data === "object" && "comments" in data
? (data as { comments?: unknown }).comments
: data;
- const comments: Comment[] = Array.isArray(raw) ? raw : [];
+ const comments: Comment[] = Array.isArray(raw)
+ ? raw.map((value) => {
+ const comment = value as Comment & { actor_kind?: unknown };
+ const actorKind = comment.actorKind ?? comment.actor_kind;
+ return actorKind === "human" || actorKind === "agent"
+ ? { ...comment, actorKind }
+ : comment;
+ })
+ : [];
const threadMap = new Map();
for (const comment of comments) {
if (!threadMap.has(comment.thread_id)) {
@@ -444,6 +453,7 @@ export function useCreateComment(author: CommentAuthor = {}) {
: parsedMentions(variables.mentions),
author_email: author.email?.trim() ?? "",
author_name: authorName(author),
+ actorKind: "human",
resolved: 0,
created_at: now,
updated_at: now,
diff --git a/templates/content/app/hooks/use-db-sync.spec.ts b/templates/content/app/hooks/use-db-sync.spec.ts
index 4a5edd45220..1731e875f41 100644
--- a/templates/content/app/hooks/use-db-sync.spec.ts
+++ b/templates/content/app/hooks/use-db-sync.spec.ts
@@ -88,6 +88,153 @@ describe("contentActionInvalidatePredicate", () => {
).toBe(false);
});
+ it.each(["reply-to-comment-ai-request", "create-comment-ai-suggestion"])(
+ "refreshes comments, request status, and proposals after %s",
+ (eventKey) => {
+ const predicate = contentActionInvalidatePredicate("/page/document-1");
+ const queries = [
+ ["action", "list-comments", { documentId: "document-1" }],
+ ["action", "list-comment-ai-requests", { documentId: "document-1" }],
+ [
+ "action",
+ "list-resource-suggestions",
+ { resourceType: "document", resourceId: "document-1" },
+ ],
+ ] as const;
+
+ for (const queryKey of queries) {
+ expect(
+ predicate({ queryKey }, [{ source: "action", key: eventKey }]),
+ ).toBe(true);
+ }
+ expect(
+ predicate(
+ { queryKey: ["action", "get-document", { id: "document-1" }] },
+ [{ source: "action", key: eventKey }],
+ ),
+ ).toBe(false);
+ },
+ );
+
+ it("refreshes request status after starting a comment AI request", () => {
+ const predicate = contentActionInvalidatePredicate("/page/document-1");
+ const event = [{ source: "action", key: "start-comment-ai-request" }];
+
+ expect(
+ predicate(
+ {
+ queryKey: [
+ "action",
+ "list-comment-ai-requests",
+ { documentId: "document-1" },
+ ],
+ },
+ event,
+ ),
+ ).toBe(true);
+ expect(
+ predicate(
+ {
+ queryKey: ["action", "list-comments", { documentId: "document-1" }],
+ },
+ event,
+ ),
+ ).toBe(false);
+ });
+
+ it("refreshes every changed comment AI surface after apply and resolve", () => {
+ const predicate = contentActionInvalidatePredicate("/page/document-1");
+ const event = [{ source: "action", key: "apply-comment-ai-request" }];
+ const queries = [
+ ["action", "get-document", { id: "document-1" }],
+ ["action", "list-comments", { documentId: "document-1" }],
+ ["action", "list-comment-ai-requests", { documentId: "document-1" }],
+ [
+ "action",
+ "list-resource-suggestions",
+ { resourceType: "document", resourceId: "document-1" },
+ ],
+ ] as const;
+
+ for (const queryKey of queries) {
+ expect(predicate({ queryKey }, event)).toBe(true);
+ }
+ });
+
+ it("refreshes only the document and proposals after a native suggestion decision", () => {
+ const predicate = contentActionInvalidatePredicate("/page/document-1");
+ const event = [{ source: "action", key: "decide-resource-suggestion" }];
+
+ expect(
+ predicate(
+ { queryKey: ["action", "get-document", { id: "document-1" }] },
+ event,
+ ),
+ ).toBe(true);
+ expect(
+ predicate(
+ {
+ queryKey: [
+ "action",
+ "list-resource-suggestions",
+ { resourceType: "document", resourceId: "document-1" },
+ ],
+ },
+ event,
+ ),
+ ).toBe(true);
+ expect(
+ predicate(
+ {
+ queryKey: ["action", "list-comments", { documentId: "document-1" }],
+ },
+ event,
+ ),
+ ).toBe(false);
+ expect(
+ predicate(
+ {
+ queryKey: [
+ "action",
+ "list-comment-ai-requests",
+ { documentId: "document-1" },
+ ],
+ },
+ event,
+ ),
+ ).toBe(false);
+ });
+
+ it("keeps comment AI refreshes scoped to the open document", () => {
+ const predicate = contentActionInvalidatePredicate("/page/document-1");
+ const event = [{ source: "action", key: "reply-to-comment-ai-request" }];
+
+ expect(
+ predicate(
+ {
+ queryKey: [
+ "action",
+ "list-comment-ai-requests",
+ { documentId: "document-2" },
+ ],
+ },
+ event,
+ ),
+ ).toBe(false);
+ expect(
+ predicate(
+ {
+ queryKey: [
+ "action",
+ "list-resource-suggestions",
+ { resourceType: "database", resourceId: "document-1" },
+ ],
+ },
+ event,
+ ),
+ ).toBe(false);
+ });
+
it("does not refresh document queries away from a document route", () => {
expect(
contentActionInvalidatePredicate("/settings")(
diff --git a/templates/content/app/i18n-data.ts b/templates/content/app/i18n-data.ts
index 2d0f6f0d2b9..4f2702d0363 100644
--- a/templates/content/app/i18n-data.ts
+++ b/templates/content/app/i18n-data.ts
@@ -3340,6 +3340,22 @@ const enUS = {
cancel: "Cancel",
submit: "Comment",
askAi: "Ask AI",
+ aiBadge: "AI",
+ aiSuggestChanges: "Suggest changes",
+ aiUnavailable: "Unavailable",
+ aiReplyInThread: "Reply in thread",
+ aiApplyAndResolve: "Apply changes and resolve",
+ aiPromptSuggest: "Suggest changes for this comment.",
+ aiPromptReply: "Reply to this comment.",
+ aiPromptApplyResolve: "Apply changes for this comment and resolve it.",
+ aiWorking: "AI is working…",
+ aiReplied: "AI replied",
+ aiSuggestionReady: "Review suggestion",
+ aiChangesApplied: "Changes applied",
+ aiNeedsReview: "Needs review",
+ aiFailed: "AI request failed",
+ retry: "Retry",
+ sourceComment: "Source comment",
resolve: "Resolve",
resolved: "Resolved ({{count}})",
unanchored: "Highlight unavailable",
@@ -9537,6 +9553,22 @@ const commentMessagesByLocale = {
saving: "正在保存…",
saveUnconfirmed: "无法确认是否已保存。请先检查此评论线程,然后再重试。",
backToList: "返回评论列表",
+ aiBadge: "AI",
+ aiSuggestChanges: "建议更改",
+ aiUnavailable: "不可用",
+ aiReplyInThread: "在线程中回复",
+ aiApplyAndResolve: "应用更改并解决",
+ aiPromptSuggest: "请为此评论建议更改。",
+ aiPromptReply: "请回复此评论。",
+ aiPromptApplyResolve: "请应用此评论中的更改并将其解决。",
+ aiWorking: "AI 正在处理…",
+ aiReplied: "AI 已回复",
+ aiSuggestionReady: "查看建议",
+ aiChangesApplied: "已应用更改",
+ aiNeedsReview: "需要审核",
+ aiFailed: "AI 请求失败",
+ retry: "重试",
+ sourceComment: "源评论",
filter: "筛选",
hideIndicators: "隐藏评论和高亮",
showIndicators: "显示评论和高亮",
@@ -9564,6 +9596,22 @@ const commentMessagesByLocale = {
saving: "正在儲存…",
saveUnconfirmed: "無法確認是否已儲存。請先檢查此留言串,再重試。",
backToList: "返回留言列表",
+ aiBadge: "AI",
+ aiSuggestChanges: "建議變更",
+ aiUnavailable: "無法使用",
+ aiReplyInThread: "在留言串中回覆",
+ aiApplyAndResolve: "套用變更並解決",
+ aiPromptSuggest: "請為此留言建議變更。",
+ aiPromptReply: "請回覆此留言。",
+ aiPromptApplyResolve: "請套用此留言中的變更並將其解決。",
+ aiWorking: "AI 正在處理…",
+ aiReplied: "AI 已回覆",
+ aiSuggestionReady: "檢視建議",
+ aiChangesApplied: "已套用變更",
+ aiNeedsReview: "需要檢視",
+ aiFailed: "AI 請求失敗",
+ retry: "重試",
+ sourceComment: "來源留言",
filter: "篩選",
hideIndicators: "隱藏留言和醒目提示",
showIndicators: "顯示留言和醒目提示",
@@ -9594,6 +9642,22 @@ const commentMessagesByLocale = {
saveUnconfirmed:
"No se pudo confirmar el guardado. Comprueba este hilo antes de volver a intentarlo.",
backToList: "Volver a comentarios",
+ aiBadge: "IA",
+ aiSuggestChanges: "Sugerir cambios",
+ aiUnavailable: "No disponible",
+ aiReplyInThread: "Responder en el hilo",
+ aiApplyAndResolve: "Aplicar cambios y resolver",
+ aiPromptSuggest: "Sugiere cambios para este comentario.",
+ aiPromptReply: "Responde a este comentario.",
+ aiPromptApplyResolve: "Aplica los cambios de este comentario y resuélvelo.",
+ aiWorking: "La IA está trabajando…",
+ aiReplied: "La IA respondió",
+ aiSuggestionReady: "Revisar sugerencia",
+ aiChangesApplied: "Cambios aplicados",
+ aiNeedsReview: "Requiere revisión",
+ aiFailed: "La solicitud de IA falló",
+ retry: "Reintentar",
+ sourceComment: "Comentario de origen",
filter: "Filtro",
hideIndicators: "Ocultar comentarios y resaltados",
showIndicators: "Mostrar comentarios y resaltados",
@@ -9624,6 +9688,23 @@ const commentMessagesByLocale = {
saveUnconfirmed:
"Impossible de confirmer l’enregistrement. Vérifiez ce fil avant de réessayer.",
backToList: "Retour aux commentaires",
+ aiBadge: "IA",
+ aiSuggestChanges: "Suggérer des modifications",
+ aiUnavailable: "Indisponible",
+ aiReplyInThread: "Répondre dans le fil",
+ aiApplyAndResolve: "Appliquer les modifications et résoudre",
+ aiPromptSuggest: "Suggère des modifications pour ce commentaire.",
+ aiPromptReply: "Réponds à ce commentaire.",
+ aiPromptApplyResolve:
+ "Applique les modifications de ce commentaire et résous-le.",
+ aiWorking: "L’IA travaille…",
+ aiReplied: "L’IA a répondu",
+ aiSuggestionReady: "Examiner la suggestion",
+ aiChangesApplied: "Modifications appliquées",
+ aiNeedsReview: "À examiner",
+ aiFailed: "La demande à l’IA a échoué",
+ retry: "Réessayer",
+ sourceComment: "Commentaire source",
filter: "Filtrer",
hideIndicators: "Masquer les commentaires et surlignages",
showIndicators: "Afficher les commentaires et surlignages",
@@ -9653,6 +9734,23 @@ const commentMessagesByLocale = {
saveUnconfirmed:
"Das Speichern konnte nicht bestätigt werden. Prüfe diesen Thread, bevor du es erneut versuchst.",
backToList: "Zurück zu den Kommentaren",
+ aiBadge: "KI",
+ aiSuggestChanges: "Änderungen vorschlagen",
+ aiUnavailable: "Nicht verfügbar",
+ aiReplyInThread: "Im Thread antworten",
+ aiApplyAndResolve: "Änderungen anwenden und erledigen",
+ aiPromptSuggest: "Schlage Änderungen für diesen Kommentar vor.",
+ aiPromptReply: "Antworte auf diesen Kommentar.",
+ aiPromptApplyResolve:
+ "Wende die Änderungen für diesen Kommentar an und erledige ihn.",
+ aiWorking: "KI arbeitet…",
+ aiReplied: "KI hat geantwortet",
+ aiSuggestionReady: "Vorschlag prüfen",
+ aiChangesApplied: "Änderungen angewendet",
+ aiNeedsReview: "Prüfung erforderlich",
+ aiFailed: "KI-Anfrage fehlgeschlagen",
+ retry: "Erneut versuchen",
+ sourceComment: "Quellkommentar",
filter: "Filter",
hideIndicators: "Kommentare und Hervorhebungen ausblenden",
showIndicators: "Kommentare und Hervorhebungen anzeigen",
@@ -9681,6 +9779,22 @@ const commentMessagesByLocale = {
saveUnconfirmed:
"保存を確認できませんでした。再試行する前にこのスレッドを確認してください。",
backToList: "コメントに戻る",
+ aiBadge: "AI",
+ aiSuggestChanges: "変更を提案",
+ aiUnavailable: "利用不可",
+ aiReplyInThread: "スレッドで返信",
+ aiApplyAndResolve: "変更を適用して解決",
+ aiPromptSuggest: "このコメントへの変更を提案してください。",
+ aiPromptReply: "このコメントに返信してください。",
+ aiPromptApplyResolve: "このコメントの変更を適用して解決してください。",
+ aiWorking: "AI が処理中…",
+ aiReplied: "AI が返信しました",
+ aiSuggestionReady: "提案を確認",
+ aiChangesApplied: "変更を適用しました",
+ aiNeedsReview: "確認が必要です",
+ aiFailed: "AI リクエストに失敗しました",
+ retry: "再試行",
+ sourceComment: "元のコメント",
filter: "フィルター",
hideIndicators: "コメントとハイライトを非表示",
showIndicators: "コメントとハイライトを表示",
@@ -9709,6 +9823,22 @@ const commentMessagesByLocale = {
saveUnconfirmed:
"저장 여부를 확인하지 못했습니다. 다시 시도하기 전에 이 스레드를 확인하세요.",
backToList: "댓글로 돌아가기",
+ aiBadge: "AI",
+ aiSuggestChanges: "변경 제안",
+ aiUnavailable: "사용할 수 없음",
+ aiReplyInThread: "스레드에 답글",
+ aiApplyAndResolve: "변경 적용 및 해결",
+ aiPromptSuggest: "이 댓글에 대한 변경 사항을 제안해 주세요.",
+ aiPromptReply: "이 댓글에 답글을 남겨 주세요.",
+ aiPromptApplyResolve: "이 댓글의 변경 사항을 적용하고 해결해 주세요.",
+ aiWorking: "AI가 작업 중…",
+ aiReplied: "AI가 답글을 남겼습니다",
+ aiSuggestionReady: "제안 검토",
+ aiChangesApplied: "변경 사항 적용됨",
+ aiNeedsReview: "검토 필요",
+ aiFailed: "AI 요청 실패",
+ retry: "다시 시도",
+ sourceComment: "원본 댓글",
filter: "필터",
hideIndicators: "댓글과 강조 표시 숨기기",
showIndicators: "댓글과 강조 표시 보기",
@@ -9739,6 +9869,22 @@ const commentMessagesByLocale = {
saveUnconfirmed:
"Não foi possível confirmar o salvamento. Verifique esta conversa antes de tentar novamente.",
backToList: "Voltar aos comentários",
+ aiBadge: "IA",
+ aiSuggestChanges: "Sugerir alterações",
+ aiUnavailable: "Indisponível",
+ aiReplyInThread: "Responder na conversa",
+ aiApplyAndResolve: "Aplicar alterações e resolver",
+ aiPromptSuggest: "Sugira alterações para este comentário.",
+ aiPromptReply: "Responda a este comentário.",
+ aiPromptApplyResolve: "Aplique as alterações deste comentário e resolva-o.",
+ aiWorking: "A IA está trabalhando…",
+ aiReplied: "A IA respondeu",
+ aiSuggestionReady: "Revisar sugestão",
+ aiChangesApplied: "Alterações aplicadas",
+ aiNeedsReview: "Precisa de revisão",
+ aiFailed: "Falha na solicitação de IA",
+ retry: "Tentar novamente",
+ sourceComment: "Comentário de origem",
filter: "Filtro",
hideIndicators: "Ocultar comentários e destaques",
showIndicators: "Mostrar comentários e destaques",
@@ -9768,6 +9914,22 @@ const commentMessagesByLocale = {
saveUnconfirmed:
"सहेजने की पुष्टि नहीं हो सकी। दोबारा कोशिश करने से पहले इस थ्रेड को जाँचें।",
backToList: "टिप्पणियों पर वापस जाएँ",
+ aiBadge: "AI",
+ aiSuggestChanges: "बदलाव सुझाएँ",
+ aiUnavailable: "उपलब्ध नहीं",
+ aiReplyInThread: "थ्रेड में जवाब दें",
+ aiApplyAndResolve: "बदलाव लागू करके सुलझाएँ",
+ aiPromptSuggest: "इस टिप्पणी के लिए बदलाव सुझाएँ।",
+ aiPromptReply: "इस टिप्पणी का जवाब दें।",
+ aiPromptApplyResolve: "इस टिप्पणी के बदलाव लागू करके इसे सुलझाएँ।",
+ aiWorking: "AI काम कर रहा है…",
+ aiReplied: "AI ने जवाब दिया",
+ aiSuggestionReady: "सुझाव की समीक्षा करें",
+ aiChangesApplied: "बदलाव लागू किए गए",
+ aiNeedsReview: "समीक्षा आवश्यक",
+ aiFailed: "AI अनुरोध विफल रहा",
+ retry: "फिर से कोशिश करें",
+ sourceComment: "मूल टिप्पणी",
filter: "फ़िल्टर",
hideIndicators: "टिप्पणियाँ और हाइलाइट छिपाएँ",
showIndicators: "टिप्पणियाँ और हाइलाइट दिखाएँ",
@@ -9801,6 +9963,22 @@ const commentMessagesByLocale = {
saveUnconfirmed:
"تعذر تأكيد الحفظ. تحقق من سلسلة التعليقات هذه قبل المحاولة مرة أخرى.",
backToList: "العودة إلى التعليقات",
+ aiBadge: "الذكاء الاصطناعي",
+ aiSuggestChanges: "اقتراح تغييرات",
+ aiUnavailable: "غير متاح",
+ aiReplyInThread: "الرد في السلسلة",
+ aiApplyAndResolve: "تطبيق التغييرات والحل",
+ aiPromptSuggest: "اقترح تغييرات لهذا التعليق.",
+ aiPromptReply: "رد على هذا التعليق.",
+ aiPromptApplyResolve: "طبّق تغييرات هذا التعليق وقم بحله.",
+ aiWorking: "الذكاء الاصطناعي يعمل…",
+ aiReplied: "رد الذكاء الاصطناعي",
+ aiSuggestionReady: "مراجعة الاقتراح",
+ aiChangesApplied: "تم تطبيق التغييرات",
+ aiNeedsReview: "بحاجة إلى مراجعة",
+ aiFailed: "فشل طلب الذكاء الاصطناعي",
+ retry: "إعادة المحاولة",
+ sourceComment: "التعليق المصدر",
filter: "تصفية",
hideIndicators: "إخفاء التعليقات والتمييزات",
showIndicators: "إظهار التعليقات والتمييزات",
diff --git a/templates/content/app/i18n/zh-TW.ts b/templates/content/app/i18n/zh-TW.ts
index 4e1d769dd5f..ab84e5c335e 100644
--- a/templates/content/app/i18n/zh-TW.ts
+++ b/templates/content/app/i18n/zh-TW.ts
@@ -653,6 +653,22 @@ const messages = {
cancel: "取消",
submit: "評論",
askAi: "詢問 AI",
+ aiBadge: "AI",
+ aiSuggestChanges: "建議變更",
+ aiUnavailable: "無法使用",
+ aiReplyInThread: "在留言串中回覆",
+ aiApplyAndResolve: "套用變更並解決",
+ aiPromptSuggest: "請為此留言建議變更。",
+ aiPromptReply: "請回覆此留言。",
+ aiPromptApplyResolve: "請套用此留言中的變更並將其解決。",
+ aiWorking: "AI 正在處理…",
+ aiReplied: "AI 已回覆",
+ aiSuggestionReady: "檢視建議",
+ aiChangesApplied: "已套用變更",
+ aiNeedsReview: "需要檢視",
+ aiFailed: "AI 請求失敗",
+ retry: "重試",
+ sourceComment: "來源留言",
resolve: "解決",
resolved: "已解決({{count}})",
unanchored: "無法使用醒目提示",
diff --git a/templates/content/docs/product/capabilities/content.comment.page-owned.md b/templates/content/docs/product/capabilities/content.comment.page-owned.md
index 44c6a37c87c..bcd5ba4a3ac 100644
--- a/templates/content/docs/product/capabilities/content.comment.page-owned.md
+++ b/templates/content/docs/product/capabilities/content.comment.page-owned.md
@@ -42,6 +42,10 @@ A reviewer comments on two Blocks in a brief, replies with a Page reference, and
- Anchors follow stable Block identity where possible and preserve historical target context after deletion rather than attaching to plausible new text.
- Resolve, reopen, edit, reply, and notification operations use shared Actions and record attributable change.
- References and embeds display the authoritative Page-owned thread; they do not clone or re-home it.
+- Ask AI presents an explicit intent before dispatch: Suggest changes, Reply in thread, or Apply changes and resolve. Suggest changes is the preferred editorial option and uses the existing suggested-edits availability and access rules.
+- Every AI request binds the authenticated requester, Page body, original root Comment and thread, submitted conversation, and revision. A scoped run can use only that intent's dedicated operation; targeting and write authority do not come from chat history or model-written arguments.
+- Reply adds an AI-attributed answer to the original thread without editing or resolving. Suggest creates a native proposal linked in both directions and leaves the original thread open, including after human acceptance. Apply-and-resolve verifies the saved edit and unchanged source conversation before resolving; conflicts and partial success remain available for review.
+- Retrying one request recovers its retained operation and result. It does not silently replace its intent, duplicate a reply or proposal, or reapply a saved edit. Unknown historical authorship is not retrospectively labeled AI.
## Boundaries and non-goals
@@ -59,6 +63,14 @@ Given a Comment on a Block range, when the range and Block are deleted, then the
Given a Page with a Comment thread is referenced or embedded elsewhere, when a viewer opens the thread from either occurrence, then they see the same Page-owned thread and access decision.
+### Choose how AI handles feedback
+
+Given an open Page-body Comment and an unfinished human reply, opening and dismissing Ask AI preserves the draft and dispatches nothing. Choosing Reply adds one AI answer to that same thread. Choosing Suggest creates a reviewable proposal without changing accepted text. Only Apply changes and resolve may change accepted text and resolve, and only after both the saved revision and original feedback have been checked.
+
+### Recover partial AI work
+
+Given an edit was saved but its receipt or resolution failed, reopening the Page exposes that partial result. Retrying the same request reuses the saved edit receipt and attempts only unfinished work. If the Page or feedback changed in the meantime, the thread stays open for review.
+
## Current evidence
`document_comments` schema and editor Comment UI/actions provide anchored threaded-comment substrate. Stable multi-Block anchors, rich universal fields, historical repair, and embed authority are not fully proven; this remains `approved_shape`.
diff --git a/templates/content/server/db/schema.ts b/templates/content/server/db/schema.ts
index d84c330a45c..002b1ed0f7a 100644
--- a/templates/content/server/db/schema.ts
+++ b/templates/content/server/db/schema.ts
@@ -8,6 +8,7 @@ import {
index,
uniqueIndex,
} from "@agent-native/core/db/schema";
+import { sql } from "drizzle-orm";
import { boolean } from "drizzle-orm/pg-core";
export const documents = table("documents", {
@@ -144,6 +145,7 @@ export const documentComments = table("document_comments", {
mentionsJson: text("mentions_json"),
authorEmail: text("author_email").notNull(),
authorName: text("author_name"),
+ actorKind: text("actor_kind"),
resolved: integer("resolved").notNull().default(0),
createdAt: text("created_at").notNull().default(now()),
updatedAt: text("updated_at").notNull().default(now()),
@@ -156,6 +158,41 @@ export const documentComments = table("document_comments", {
notionDiscussionId: text("notion_discussion_id"),
});
+export const commentAiRequests = table(
+ "comment_ai_requests",
+ {
+ id: text("id").primaryKey(),
+ ownerEmail: text("owner_email").notNull(),
+ requesterEmail: text("requester_email").notNull(),
+ documentId: text("document_id").notNull(),
+ threadId: text("thread_id").notNull(),
+ rootCommentId: text("root_comment_id").notNull(),
+ fieldId: text("field_id").notNull(),
+ intent: text("intent").notNull(),
+ status: text("status").notNull().default("queued"),
+ threadDigest: text("thread_digest").notNull(),
+ snapshotJson: text("snapshot_json").notNull(),
+ baseRevision: text("base_revision").notNull(),
+ suggestionRevision: text("suggestion_revision").notNull(),
+ runId: text("run_id"),
+ agentThreadId: text("agent_thread_id"),
+ resultJson: text("result_json"),
+ payloadJson: text("payload_json"),
+ error: text("error"),
+ createdAt: text("created_at").notNull().default(now()),
+ updatedAt: text("updated_at").notNull().default(now()),
+ },
+ (request) => [
+ uniqueIndex("comment_ai_requests_active_thread_idx")
+ .on(request.documentId, request.threadId, request.requesterEmail)
+ .where(sql`${request.status} IN ('queued', 'running')`),
+ index("comment_ai_requests_document_requester_idx").on(
+ request.documentId,
+ request.requesterEmail,
+ ),
+ ],
+);
+
export const documentSyncLinks = table("document_sync_links", {
documentId: text("document_id").primaryKey(),
ownerEmail: text("owner_email").notNull().default("local@localhost"),
diff --git a/templates/content/server/lib/comment-ai-progress.test.ts b/templates/content/server/lib/comment-ai-progress.test.ts
new file mode 100644
index 00000000000..24c09404dbc
--- /dev/null
+++ b/templates/content/server/lib/comment-ai-progress.test.ts
@@ -0,0 +1,149 @@
+import { rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+import { runWithRequestContext } from "@agent-native/core/server";
+import {
+ afterAll,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from "vitest";
+
+const progress = vi.hoisted(() => ({
+ getRunStatus: vi.fn(),
+ getRunTurnRef: vi.fn(),
+ getActiveRunForThreadAsync: vi.fn(),
+}));
+
+vi.mock("@agent-native/core/server", async (importOriginal) => ({
+ ...(await importOriginal()),
+ ...progress,
+}));
+
+const TEST_DB_PATH = join(
+ tmpdir(),
+ `content-comment-ai-progress-${process.pid}-${Date.now()}.pglite`,
+);
+const OWNER = "comment-ai-progress@example.com";
+const DOCUMENT_ID = "comment-ai-progress-page";
+const REQUEST_ID = "11111111-1111-4111-8111-111111111111";
+const ORIGIN_RUN_ID = "origin-run";
+const AGENT_THREAD_ID = "agent-thread";
+
+let getDb: typeof import("../db/index.js").getDb;
+let schema: typeof import("../db/schema.js");
+let listCommentAiRequests: typeof import("./comment-ai.js").listCommentAiRequests;
+
+const asOwner = (run: () => Promise) =>
+ runWithRequestContext({ userEmail: OWNER }, run);
+
+beforeAll(async () => {
+ process.env.DATABASE_URL = `pglite:${TEST_DB_PATH}`;
+ const dbModule = await import("../db/index.js");
+ getDb = dbModule.getDb;
+ schema = dbModule.schema;
+ const plugin = (await import("../plugins/db.js")).default;
+ await plugin(undefined as never);
+ ({ listCommentAiRequests } = await import("./comment-ai.js"));
+}, 60_000);
+
+beforeEach(async () => {
+ vi.clearAllMocks();
+ await getDb().delete(schema.commentAiRequests);
+ await getDb().delete(schema.documents);
+
+ const now = new Date().toISOString();
+ await getDb().insert(schema.documents).values({
+ id: DOCUMENT_ID,
+ ownerEmail: OWNER,
+ title: "Comment AI progress",
+ content: "Page body",
+ bodyRevision: 1,
+ createdAt: now,
+ updatedAt: now,
+ });
+ await getDb().insert(schema.commentAiRequests).values({
+ id: REQUEST_ID,
+ ownerEmail: OWNER,
+ requesterEmail: OWNER,
+ documentId: DOCUMENT_ID,
+ threadId: "comment-thread",
+ rootCommentId: "root-comment",
+ fieldId: "body",
+ intent: "reply",
+ status: "running",
+ threadDigest: "digest",
+ snapshotJson: "[]",
+ baseRevision: "base-revision",
+ suggestionRevision: now,
+ runId: ORIGIN_RUN_ID,
+ agentThreadId: AGENT_THREAD_ID,
+ createdAt: now,
+ updatedAt: now,
+ });
+ progress.getRunStatus.mockResolvedValue("completed");
+ progress.getRunTurnRef.mockResolvedValue({
+ threadId: AGENT_THREAD_ID,
+ turnId: "turn-a",
+ });
+});
+
+afterAll(() => {
+ rmSync(TEST_DB_PATH, { force: true, recursive: true });
+});
+
+describe("comment AI request run progress", () => {
+ it("keeps a request active while a same-turn successor is running", async () => {
+ progress.getActiveRunForThreadAsync.mockResolvedValue({
+ runId: "successor-run",
+ threadId: AGENT_THREAD_ID,
+ turnId: "turn-a",
+ status: "running",
+ });
+
+ const result = await asOwner(() => listCommentAiRequests(DOCUMENT_ID));
+
+ expect(result.requests[0]).toMatchObject({
+ requestId: REQUEST_ID,
+ status: "running",
+ error: null,
+ });
+ expect(progress.getRunTurnRef).toHaveBeenCalledWith(ORIGIN_RUN_ID);
+ expect(progress.getActiveRunForThreadAsync).toHaveBeenCalledWith(
+ AGENT_THREAD_ID,
+ );
+ });
+
+ it("does not adopt a running successor from another turn", async () => {
+ progress.getActiveRunForThreadAsync.mockResolvedValue({
+ runId: "unrelated-run",
+ threadId: AGENT_THREAD_ID,
+ turnId: "turn-b",
+ status: "running",
+ });
+
+ const result = await asOwner(() => listCommentAiRequests(DOCUMENT_ID));
+
+ expect(result.requests[0]).toMatchObject({
+ requestId: REQUEST_ID,
+ status: "needs-review",
+ error: "The agent run ended (completed) before this request completed",
+ });
+ });
+
+ it("reports review needed when a terminal origin has no successor", async () => {
+ progress.getActiveRunForThreadAsync.mockResolvedValue(null);
+
+ const result = await asOwner(() => listCommentAiRequests(DOCUMENT_ID));
+
+ expect(result.requests[0]).toMatchObject({
+ requestId: REQUEST_ID,
+ status: "needs-review",
+ error: "The agent run ended (completed) before this request completed",
+ });
+ });
+});
diff --git a/templates/content/server/lib/comment-ai.spec.ts b/templates/content/server/lib/comment-ai.spec.ts
new file mode 100644
index 00000000000..d5486edfbbb
--- /dev/null
+++ b/templates/content/server/lib/comment-ai.spec.ts
@@ -0,0 +1,213 @@
+import { rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+import { runWithRequestContext } from "@agent-native/core/server";
+import { eq } from "drizzle-orm";
+import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
+
+const TEST_DB_PATH = join(
+ tmpdir(),
+ `content-comment-ai-${process.pid}-${Date.now()}.pglite`,
+);
+const OWNER = "comment-ai-owner@example.com";
+const OUTSIDER = "comment-ai-outsider@example.com";
+const DOCUMENT_ID = "comment-ai-page";
+const THREAD_ID = "comment-ai-thread";
+const ROOT_COMMENT_ID = "comment-ai-root";
+const FIRST_REQUEST_ID = "11111111-1111-4111-8111-111111111111";
+const SECOND_REQUEST_ID = "22222222-2222-4222-8222-222222222222";
+const THIRD_REQUEST_ID = "33333333-3333-4333-8333-333333333333";
+
+let getDb: typeof import("../db/index.js").getDb;
+let schema: typeof import("../db/schema.js");
+let commentAi: typeof import("./comment-ai.js");
+let commentIdForIdempotency: typeof import("../../actions/add-comment.js").commentIdForIdempotency;
+
+const asUser = (userEmail: string, run: () => Promise) =>
+ runWithRequestContext({ userEmail }, run);
+
+function startArgs(requestId = FIRST_REQUEST_ID) {
+ return {
+ requestId,
+ documentId: DOCUMENT_ID,
+ threadId: THREAD_ID,
+ rootCommentId: ROOT_COMMENT_ID,
+ intent: "reply" as const,
+ };
+}
+
+beforeAll(async () => {
+ process.env.DATABASE_URL = `pglite:${TEST_DB_PATH}`;
+ const dbModule = await import("../db/index.js");
+ getDb = dbModule.getDb;
+ schema = dbModule.schema;
+ const plugin = (await import("../plugins/db.js")).default;
+ await plugin(undefined as never);
+ commentAi = await import("./comment-ai.js");
+ ({ commentIdForIdempotency } = await import("../../actions/add-comment.js"));
+}, 60_000);
+
+beforeEach(async () => {
+ await getDb().delete(schema.commentAiRequests);
+ await getDb().delete(schema.documentComments);
+ await getDb().delete(schema.documents);
+
+ const now = new Date().toISOString();
+ await getDb().insert(schema.documents).values({
+ id: DOCUMENT_ID,
+ ownerEmail: OWNER,
+ title: "Comment AI page",
+ content: "Page body",
+ bodyRevision: 1,
+ createdAt: now,
+ updatedAt: now,
+ });
+ await getDb().insert(schema.documentComments).values({
+ id: ROOT_COMMENT_ID,
+ ownerEmail: OWNER,
+ documentId: DOCUMENT_ID,
+ threadId: THREAD_ID,
+ parentId: null,
+ content: "Please explain this paragraph",
+ authorEmail: OWNER,
+ authorName: "Owner",
+ createdAt: now,
+ updatedAt: now,
+ });
+});
+
+afterAll(() => {
+ rmSync(TEST_DB_PATH, { force: true, recursive: true });
+});
+
+describe("comment AI request persistence", () => {
+ it("binds a request ID immutably and keeps it private to its requester", async () => {
+ await asUser(OWNER, () => commentAi.startCommentAiRequest(startArgs()));
+
+ await expect(
+ asUser(OWNER, () =>
+ commentAi.startCommentAiRequest({
+ ...startArgs(),
+ intent: "apply-resolve",
+ }),
+ ),
+ ).rejects.toThrow("already bound to another comment or intent");
+ await expect(
+ asUser(OUTSIDER, () => commentAi.loadCommentAiRequest(FIRST_REQUEST_ID)),
+ ).rejects.toThrow("Comment AI request not found");
+ });
+
+ it("reuses a queued or running request for the same requester and thread", async () => {
+ const first = await asUser(OWNER, () =>
+ commentAi.startCommentAiRequest(startArgs()),
+ );
+ const queuedReplay = await asUser(OWNER, () =>
+ commentAi.startCommentAiRequest(startArgs(SECOND_REQUEST_ID)),
+ );
+ await getDb()
+ .update(schema.commentAiRequests)
+ .set({ status: "running" })
+ .where(eq(schema.commentAiRequests.id, FIRST_REQUEST_ID));
+ const runningReplay = await asUser(OWNER, () =>
+ commentAi.startCommentAiRequest(startArgs(THIRD_REQUEST_ID)),
+ );
+
+ expect(queuedReplay.requestId).toBe(first.requestId);
+ expect(runningReplay.requestId).toBe(first.requestId);
+ const rows = await getDb().select().from(schema.commentAiRequests);
+ expect(rows).toHaveLength(1);
+ });
+
+ it("reconciles simultaneous starts with distinct IDs into one active request", async () => {
+ const results = await Promise.all(
+ [FIRST_REQUEST_ID, SECOND_REQUEST_ID, THIRD_REQUEST_ID].map((id) =>
+ asUser(OWNER, () => commentAi.startCommentAiRequest(startArgs(id))),
+ ),
+ );
+ expect(new Set(results.map((result) => result.requestId)).size).toBe(1);
+ expect(results.filter((result) => result.dispatch)).toHaveLength(1);
+ expect(await getDb().select().from(schema.commentAiRequests)).toHaveLength(
+ 1,
+ );
+ });
+
+ it("atomically reclaims a failed request once for concurrent retries", async () => {
+ await asUser(OWNER, () => commentAi.startCommentAiRequest(startArgs()));
+ await getDb()
+ .update(schema.commentAiRequests)
+ .set({ status: "needs-review" })
+ .where(eq(schema.commentAiRequests.id, FIRST_REQUEST_ID));
+ const retries = await Promise.all(
+ [1, 2, 3].map(() =>
+ asUser(OWNER, () => commentAi.startCommentAiRequest(startArgs())),
+ ),
+ );
+ expect(retries.filter((result) => result.dispatch)).toHaveLength(1);
+ expect(retries.every((result) => result.status === "queued")).toBe(true);
+ });
+
+ it("excludes only its deterministic receipt from source drift detection", async () => {
+ await asUser(OWNER, () => commentAi.startCommentAiRequest(startArgs()));
+ const request = await asUser(OWNER, () =>
+ commentAi.loadCommentAiRequest(FIRST_REQUEST_ID),
+ );
+ const receiptId = commentIdForIdempotency(
+ OWNER,
+ DOCUMENT_ID,
+ `comment-ai:${FIRST_REQUEST_ID}:reply`,
+ );
+ const now = new Date().toISOString();
+ await getDb().insert(schema.documentComments).values({
+ id: receiptId,
+ ownerEmail: OWNER,
+ documentId: DOCUMENT_ID,
+ threadId: THREAD_ID,
+ parentId: ROOT_COMMENT_ID,
+ content: "AI reply",
+ authorEmail: OWNER,
+ authorName: "AI",
+ actorKind: "agent",
+ createdAt: now,
+ updatedAt: now,
+ });
+
+ await expect(
+ asUser(OWNER, () => commentAi.assertCommentAiSourceUnchanged(request)),
+ ).resolves.toMatchObject({ root: { id: ROOT_COMMENT_ID } });
+
+ await getDb()
+ .update(schema.documentComments)
+ .set({ content: "The original comment changed" })
+ .where(eq(schema.documentComments.id, ROOT_COMMENT_ID));
+ await expect(
+ asUser(OWNER, () => commentAi.assertCommentAiSourceUnchanged(request)),
+ ).rejects.toThrow("comment changed during this request");
+ });
+
+ it("preserves a terminal success and its receipt after a late failure", async () => {
+ await asUser(OWNER, () => commentAi.startCommentAiRequest(startArgs()));
+ const request = await asUser(OWNER, () =>
+ commentAi.loadCommentAiRequest(FIRST_REQUEST_ID),
+ );
+
+ await asUser(OWNER, () =>
+ commentAi.updateCommentAiRequest(request, {
+ status: "replied",
+ result: { commentId: "saved-comment" },
+ }),
+ );
+ const late = await asUser(OWNER, () =>
+ commentAi.updateCommentAiRequest(request, {
+ status: "failed",
+ error: "late worker failure",
+ }),
+ );
+
+ expect(late).toMatchObject({
+ status: "replied",
+ result: { commentId: "saved-comment" },
+ error: null,
+ });
+ });
+});
diff --git a/templates/content/server/lib/comment-ai.ts b/templates/content/server/lib/comment-ai.ts
new file mode 100644
index 00000000000..cbbe8915cee
--- /dev/null
+++ b/templates/content/server/lib/comment-ai.ts
@@ -0,0 +1,576 @@
+import { createHash } from "node:crypto";
+
+import type { ActionRunContext } from "@agent-native/core/action";
+import { writeAppState } from "@agent-native/core/application-state";
+import { isFeatureFlagEnabled } from "@agent-native/core/feature-flags";
+import {
+ getRequestRunContext,
+ getRequestUserEmail,
+ getRunStatus,
+ getRunTurnRef,
+ getActiveRunForThreadAsync,
+} from "@agent-native/core/server";
+import { assertAccess } from "@agent-native/core/sharing";
+import {
+ and,
+ asc,
+ desc,
+ eq,
+ isNull,
+ inArray,
+ notInArray,
+ sql,
+} from "drizzle-orm";
+import { z } from "zod";
+
+import { documentRevisionToken } from "../../actions/_document-edit-mutation.js";
+import { commentIdForIdempotency } from "../../actions/add-comment.js";
+import type {
+ CommentAiIntent,
+ CommentAiRequest,
+ StartCommentAiResult,
+} from "../../shared/comment-ai.js";
+import { CONTENT_SUGGESTED_EDITS_FLAG } from "../../shared/feature-flags.js";
+import { getDb, schema } from "../db/index.js";
+
+export const commentAiIntentSchema = z.enum([
+ "suggest",
+ "reply",
+ "apply-resolve",
+]);
+export const commentAiScopeSchema = z
+ .object({
+ kind: z.literal("content-comment-ai"),
+ requestId: z.string().uuid(),
+ })
+ .strict();
+const statusSchema = z.enum([
+ "queued",
+ "running",
+ "replied",
+ "suggested",
+ "resolved",
+ "needs-review",
+ "failed",
+]);
+const resultSchema = z.object({
+ commentId: z.string().optional(),
+ suggestionId: z.string().optional(),
+ editApplied: z.boolean().optional(),
+ resolved: z.boolean().optional(),
+});
+type RequestRow = typeof schema.commentAiRequests.$inferSelect;
+type RequestSummaryRow = Pick<
+ RequestRow,
+ | "id"
+ | "documentId"
+ | "threadId"
+ | "rootCommentId"
+ | "intent"
+ | "status"
+ | "runId"
+ | "agentThreadId"
+ | "resultJson"
+ | "error"
+ | "createdAt"
+ | "updatedAt"
+>;
+type CommentRow = typeof schema.documentComments.$inferSelect;
+
+export function commentThreadDigest(
+ comments: Pick<
+ CommentRow,
+ | "id"
+ | "parentId"
+ | "content"
+ | "resolved"
+ | "quotedText"
+ | "anchorPrefix"
+ | "anchorSuffix"
+ | "anchorStartOffset"
+ >[],
+) {
+ return createHash("sha256")
+ .update(
+ JSON.stringify(
+ comments
+ .map((c) => ({
+ id: c.id,
+ parentId: c.parentId,
+ content: c.content,
+ resolved: c.resolved,
+ quotedText: c.quotedText,
+ anchorPrefix: c.anchorPrefix,
+ anchorSuffix: c.anchorSuffix,
+ anchorStartOffset: c.anchorStartOffset,
+ }))
+ .sort((a, b) => a.id.localeCompare(b.id)),
+ ),
+ )
+ .digest("hex");
+}
+
+export function serializeCommentAiRequest(
+ row: RequestSummaryRow,
+): CommentAiRequest {
+ return {
+ requestId: row.id,
+ documentId: row.documentId,
+ threadId: row.threadId,
+ rootCommentId: row.rootCommentId,
+ intent: commentAiIntentSchema.parse(row.intent),
+ status: statusSchema.parse(row.status),
+ runId: row.runId,
+ agentThreadId: row.agentThreadId,
+ result:
+ row.resultJson === null
+ ? null
+ : resultSchema.parse(JSON.parse(row.resultJson)),
+ error: row.error,
+ createdAt: row.createdAt,
+ updatedAt: row.updatedAt,
+ };
+}
+
+export async function loadCommentAiRequest(
+ id: string,
+ email = getRequestUserEmail(),
+) {
+ if (!email) throw new Error("Sign in to use Ask AI");
+ const [request] = await getDb()
+ .select()
+ .from(schema.commentAiRequests)
+ .where(
+ and(
+ eq(schema.commentAiRequests.id, id),
+ eq(schema.commentAiRequests.requesterEmail, email),
+ ),
+ )
+ .limit(1);
+ if (!request) throw new Error("Comment AI request not found");
+ await assertAccess(
+ "document",
+ request.documentId,
+ request.intent === "apply-resolve" ? "editor" : "commenter",
+ );
+ return request;
+}
+
+export async function readCommentAiSource(
+ request: Pick<
+ RequestRow,
+ "documentId" | "threadId" | "rootCommentId" | "intent"
+ >,
+) {
+ const access = await assertAccess(
+ "document",
+ request.documentId,
+ request.intent === "apply-resolve" ? "editor" : "commenter",
+ );
+ const [document] = await getDb()
+ .select()
+ .from(schema.documents)
+ .where(
+ and(
+ eq(schema.documents.id, request.documentId),
+ eq(schema.documents.ownerEmail, access.resource.ownerEmail as string),
+ ),
+ )
+ .limit(1);
+ if (!document || document.trashedAt || document.sourceMode)
+ throw new Error("This Page is unavailable for Ask AI");
+ const comments = await getDb()
+ .select()
+ .from(schema.documentComments)
+ .where(
+ and(
+ eq(schema.documentComments.documentId, request.documentId),
+ eq(schema.documentComments.threadId, request.threadId),
+ eq(schema.documentComments.ownerEmail, document.ownerEmail),
+ ),
+ )
+ .orderBy(
+ asc(schema.documentComments.createdAt),
+ asc(schema.documentComments.id),
+ );
+ const root = comments.find(
+ (c) => c.id === request.rootCommentId && c.parentId === null,
+ );
+ if (!root)
+ throw new Error(
+ "The selected comment no longer belongs to this Page and thread",
+ );
+ return { document, comments, root };
+}
+
+async function hasActiveSuccessor(
+ request: RequestSummaryRow,
+): Promise {
+ if (!request.runId || !request.agentThreadId) return false;
+ const [origin, successor] = await Promise.all([
+ getRunTurnRef(request.runId),
+ getActiveRunForThreadAsync(request.agentThreadId),
+ ]);
+ return Boolean(
+ origin &&
+ successor &&
+ successor.status === "running" &&
+ origin.threadId === successor.threadId &&
+ origin.turnId === successor.turnId,
+ );
+}
+
+export async function startCommentAiRequest(
+ args: {
+ requestId: string;
+ documentId: string;
+ threadId: string;
+ rootCommentId: string;
+ intent: CommentAiIntent;
+ },
+ ctx?: ActionRunContext,
+): Promise {
+ const email = getRequestUserEmail();
+ if (!email) throw new Error("Sign in to use Ask AI");
+ let existing = await getDb()
+ .select()
+ .from(schema.commentAiRequests)
+ .where(eq(schema.commentAiRequests.id, args.requestId))
+ .limit(1);
+ if (!existing.length) {
+ existing = await getDb()
+ .select()
+ .from(schema.commentAiRequests)
+ .where(
+ and(
+ eq(schema.commentAiRequests.documentId, args.documentId),
+ eq(schema.commentAiRequests.threadId, args.threadId),
+ eq(schema.commentAiRequests.requesterEmail, email),
+ inArray(schema.commentAiRequests.status, ["queued", "running"]),
+ ),
+ )
+ .limit(1);
+ }
+ let request: RequestRow;
+ let dispatch = false;
+ if (existing[0]) {
+ request = await loadCommentAiRequest(existing[0].id);
+ if (
+ request.documentId !== args.documentId ||
+ request.threadId !== args.threadId ||
+ request.rootCommentId !== args.rootCommentId ||
+ request.intent !== args.intent
+ )
+ throw new Error(
+ "This request ID is already bound to another comment or intent",
+ );
+ } else {
+ const { document, comments, root } = await readCommentAiSource(args);
+ if (root.resolved) throw new Error("Reopen the comment before asking AI");
+ if (
+ args.intent === "suggest" &&
+ !(await isFeatureFlagEnabled(CONTENT_SUGGESTED_EDITS_FLAG, ctx))
+ )
+ throw new Error("Suggested edits are not enabled for this account");
+ if (
+ comments.length > 100 ||
+ comments.reduce((size, c) => size + c.content.length, 0) > 24000
+ )
+ throw new Error(
+ "This conversation is too large for one comment AI request",
+ );
+ const inserted = await getDb()
+ .insert(schema.commentAiRequests)
+ .values({
+ id: args.requestId,
+ ownerEmail: document.ownerEmail,
+ requesterEmail: email,
+ documentId: args.documentId,
+ threadId: args.threadId,
+ rootCommentId: args.rootCommentId,
+ fieldId: "body",
+ intent: args.intent,
+ threadDigest: commentThreadDigest(comments),
+ snapshotJson: JSON.stringify(
+ comments.map((c) => ({
+ id: c.id,
+ author: c.authorName,
+ content: c.content,
+ })),
+ ),
+ baseRevision: documentRevisionToken(
+ document.bodyRevision,
+ document.content,
+ ),
+ suggestionRevision: document.updatedAt,
+ })
+ .onConflictDoNothing()
+ .returning();
+ if (inserted[0]) {
+ request = inserted[0];
+ dispatch = true;
+ } else {
+ const [winner] = await getDb()
+ .select()
+ .from(schema.commentAiRequests)
+ .where(
+ and(
+ eq(schema.commentAiRequests.documentId, args.documentId),
+ eq(schema.commentAiRequests.threadId, args.threadId),
+ eq(schema.commentAiRequests.requesterEmail, email),
+ inArray(schema.commentAiRequests.status, ["queued", "running"]),
+ ),
+ )
+ .limit(1);
+ request = await loadCommentAiRequest(winner?.id ?? args.requestId);
+ }
+ if (
+ request.documentId !== args.documentId ||
+ request.threadId !== args.threadId ||
+ request.rootCommentId !== args.rootCommentId ||
+ request.intent !== args.intent
+ )
+ throw new Error(
+ "This request ID is already bound to another comment or intent",
+ );
+ }
+ if (
+ !dispatch &&
+ !["replied", "suggested", "resolved"].includes(request.status)
+ ) {
+ const recoverable =
+ request.status === "needs-review" ||
+ request.status === "failed" ||
+ (request.runId
+ ? (await getRunStatus(request.runId)) !== "running" &&
+ !(await hasActiveSuccessor(request))
+ : Date.now() - Date.parse(request.updatedAt) > 60000);
+ if (recoverable) {
+ const [claimed] = await getDb()
+ .update(schema.commentAiRequests)
+ .set({
+ status: "queued",
+ runId: null,
+ error: null,
+ updatedAt: new Date().toISOString(),
+ })
+ .where(
+ and(
+ eq(schema.commentAiRequests.id, request.id),
+ eq(schema.commentAiRequests.status, request.status),
+ eq(schema.commentAiRequests.updatedAt, request.updatedAt),
+ request.runId
+ ? eq(schema.commentAiRequests.runId, request.runId)
+ : isNull(schema.commentAiRequests.runId),
+ ),
+ )
+ .returning();
+ dispatch = Boolean(claimed);
+ request = claimed ?? (await loadCommentAiRequest(request.id));
+ }
+ }
+ const intent = {
+ suggest: "Suggest changes",
+ reply: "Reply in thread",
+ "apply-resolve": "Apply changes and resolve",
+ }[args.intent];
+ await writeAppState("comment-ai-request", {
+ requestId: request.id,
+ documentId: request.documentId,
+ fieldId: request.fieldId,
+ threadId: request.threadId,
+ intent: request.intent,
+ });
+ return {
+ ...serializeCommentAiRequest(request),
+ dispatch,
+ actionScope: { kind: "content-comment-ai", requestId: request.id },
+ prompt: `${intent} for this comment.`,
+ context: `Original comment: /page/${encodeURIComponent(request.documentId)}?comment=${encodeURIComponent(request.threadId)}. The scoped context action contains the submitted conversation and current Page body.`,
+ };
+}
+
+export async function resolveCommentAiActionSurface(details: {
+ actionScope?: unknown;
+ ownerEmail: string | null;
+ threadId?: string;
+}) {
+ if (details.actionScope === undefined) return { mode: "default" as const };
+ const scope = commentAiScopeSchema.parse(details.actionScope);
+ const request = await loadCommentAiRequest(
+ scope.requestId,
+ details.ownerEmail ?? undefined,
+ );
+ await getDb()
+ .update(schema.commentAiRequests)
+ .set({ agentThreadId: details.threadId ?? request.agentThreadId })
+ .where(eq(schema.commentAiRequests.id, request.id));
+ const operation = {
+ reply: "reply-to-comment-ai-request",
+ suggest: "create-comment-ai-suggestion",
+ "apply-resolve": "apply-comment-ai-request",
+ }[commentAiIntentSchema.parse(request.intent)];
+ return {
+ allowedActionNames: ["get-comment-ai-context", operation],
+ actionScope: scope,
+ };
+}
+
+export async function requireCommentAiRequest(intent?: CommentAiIntent) {
+ const run = getRequestRunContext();
+ if (!run) throw new Error("This operation requires a scoped comment AI run");
+ const scope = commentAiScopeSchema.parse(run.actionScope);
+ const request = await loadCommentAiRequest(scope.requestId);
+ if (intent && request.intent !== intent)
+ throw new Error(
+ "This operation is not permitted by the selected comment intent",
+ );
+ if (request.fieldId !== "body")
+ throw new Error("Unsupported Blocks field for this comment request");
+ await getDb()
+ .update(schema.commentAiRequests)
+ .set({
+ runId: run.runId,
+ agentThreadId: run.threadId,
+ updatedAt: new Date().toISOString(),
+ })
+ .where(eq(schema.commentAiRequests.id, request.id));
+ return request;
+}
+
+export async function assertCommentAiSourceUnchanged(request: RequestRow) {
+ const source = await readCommentAiSource(request);
+ const receiptId = commentIdForIdempotency(
+ request.requesterEmail,
+ request.documentId,
+ `comment-ai:${request.id}:${request.intent === "reply" ? "reply" : "receipt"}`,
+ );
+ if (
+ commentThreadDigest(source.comments.filter((c) => c.id !== receiptId)) !==
+ request.threadDigest
+ )
+ throw new Error(
+ "The comment changed during this request. Its thread remains open for review.",
+ );
+ return source;
+}
+
+export async function updateCommentAiRequest(
+ request: RequestRow,
+ updates: {
+ status: CommentAiRequest["status"];
+ result?: CommentAiRequest["result"];
+ error?: string | null;
+ },
+) {
+ const [row] = await getDb()
+ .update(schema.commentAiRequests)
+ .set({
+ status: updates.status,
+ ...(updates.result != null
+ ? {
+ resultJson: sql`(COALESCE(${schema.commentAiRequests.resultJson}, '{}')::jsonb || ${JSON.stringify(updates.result)}::jsonb)::text`,
+ }
+ : {}),
+ error: updates.error ?? null,
+ updatedAt: new Date().toISOString(),
+ })
+ .where(
+ and(
+ eq(schema.commentAiRequests.id, request.id),
+ notInArray(schema.commentAiRequests.status, [
+ "replied",
+ "suggested",
+ "resolved",
+ ]),
+ ),
+ )
+ .returning();
+ if (!row)
+ return serializeCommentAiRequest(await loadCommentAiRequest(request.id));
+ await writeAppState("refresh-signal", { ts: Date.now() });
+ return serializeCommentAiRequest(row);
+}
+
+export async function retainCommentAiPayload(
+ request: RequestRow,
+ payload: T,
+): Promise {
+ await getDb()
+ .update(schema.commentAiRequests)
+ .set({
+ payloadJson: JSON.stringify(payload),
+ status: "running",
+ error: null,
+ })
+ .where(
+ and(
+ eq(schema.commentAiRequests.id, request.id),
+ isNull(schema.commentAiRequests.payloadJson),
+ ),
+ );
+ const current = await loadCommentAiRequest(request.id);
+ if (!current.payloadJson)
+ throw new Error("Comment AI operation payload was not retained");
+ return JSON.parse(current.payloadJson) as T;
+}
+
+export async function listCommentAiRequests(documentId: string) {
+ await assertAccess("document", documentId, "viewer");
+ const email = getRequestUserEmail();
+ if (!email) throw new Error("Sign in to read comment AI requests");
+ const rows = await getDb()
+ .select({
+ id: schema.commentAiRequests.id,
+ documentId: schema.commentAiRequests.documentId,
+ threadId: schema.commentAiRequests.threadId,
+ rootCommentId: schema.commentAiRequests.rootCommentId,
+ intent: schema.commentAiRequests.intent,
+ status: schema.commentAiRequests.status,
+ runId: schema.commentAiRequests.runId,
+ agentThreadId: schema.commentAiRequests.agentThreadId,
+ resultJson: schema.commentAiRequests.resultJson,
+ error: schema.commentAiRequests.error,
+ createdAt: schema.commentAiRequests.createdAt,
+ updatedAt: schema.commentAiRequests.updatedAt,
+ })
+ .from(schema.commentAiRequests)
+ .where(
+ and(
+ eq(schema.commentAiRequests.documentId, documentId),
+ eq(schema.commentAiRequests.requesterEmail, email),
+ ),
+ )
+ .orderBy(desc(schema.commentAiRequests.createdAt))
+ .limit(100);
+ const requests = await Promise.all(
+ rows.map(async (row) => {
+ const request = serializeCommentAiRequest(row);
+ if (request.status !== "queued" && request.status !== "running")
+ return request;
+ if (request.runId) {
+ const runStatus = await getRunStatus(request.runId);
+ if (runStatus !== "running" && (await hasActiveSuccessor(row)))
+ return request;
+ if (runStatus !== "running")
+ return {
+ ...request,
+ status: "needs-review" as const,
+ error:
+ runStatus === null
+ ? "Run status is unavailable; reconcile this request before retrying"
+ : `The agent run ended (${runStatus}) before this request completed`,
+ };
+ } else if (Date.now() - Date.parse(request.updatedAt) > 60000) {
+ return {
+ ...request,
+ status: "needs-review" as const,
+ error:
+ "No operation has been recorded yet. Retry this same request to recover its result.",
+ };
+ }
+ return request;
+ }),
+ );
+ return { requests };
+}
diff --git a/templates/content/server/lib/suggested-edits.spec.ts b/templates/content/server/lib/suggested-edits.spec.ts
index 26047f2c72a..e94bda4c9f5 100644
--- a/templates/content/server/lib/suggested-edits.spec.ts
+++ b/templates/content/server/lib/suggested-edits.spec.ts
@@ -75,6 +75,19 @@ describe("Content document suggestion adapter", () => {
expect(exclusions).toHaveBeenCalledOnce();
});
+ it("uses the proposal transaction for database validation", async () => {
+ const execute = vi.fn(async () => ({ rows: [] }));
+ await contentDocumentSuggestionAdapter.validateProposal({
+ resourceType: "document",
+ resourceId: "doc-1",
+ baseRevision: "rev-1",
+ operations: [operation],
+ ctx: { suggestionAccess: access, transaction: { execute } },
+ });
+ expect(execute).toHaveBeenCalledOnce();
+ expect(exclusions).not.toHaveBeenCalled();
+ });
+
it("rejects inline-database pages before creating a pending suggestion", async () => {
const markdown = 'Before\n\n';
await expect(
diff --git a/templates/content/server/lib/suggested-edits.ts b/templates/content/server/lib/suggested-edits.ts
index 3cd39b58378..f7bd0a0edbb 100644
--- a/templates/content/server/lib/suggested-edits.ts
+++ b/templates/content/server/lib/suggested-edits.ts
@@ -195,7 +195,9 @@ export const contentDocumentSuggestionAdapter: SuggestionAdapter = {
if (document.updatedAt !== input.baseRevision) {
throw new Error("The Page changed before the suggestion was created");
}
- const exclusions = await getDbExec().execute({
+ const proposalDb =
+ (input.ctx?.transaction as DbExec | undefined) ?? getDbExec();
+ const exclusions = await proposalDb.execute({
sql: `SELECT 'database' AS kind
FROM content_database_items i
INNER JOIN content_databases d ON d.id = i.database_id
diff --git a/templates/content/server/plugins/agent-chat.ts b/templates/content/server/plugins/agent-chat.ts
index a97a8e18c27..ad825173e37 100644
--- a/templates/content/server/plugins/agent-chat.ts
+++ b/templates/content/server/plugins/agent-chat.ts
@@ -8,6 +8,7 @@ import { and, desc, eq, notInArray } from "drizzle-orm";
import actionsRegistry from "../../.generated/actions-registry.js";
import * as schema from "../db/schema.js";
+import { resolveCommentAiActionSurface } from "../lib/comment-ai.js";
import {
documentVersionChatContextFromRun,
serializeDocumentVersionChatContext,
@@ -176,6 +177,8 @@ async function autosaveDocumentAfterAgentTurn(
export default createAgentChatPlugin({
appId: "content",
+ nativeActionsInDev: true,
+ resolveActionSurface: resolveCommentAiActionSurface,
onAgentTurnComplete: autosaveDocumentAfterAgentTurn,
durableBackgroundRuns: true,
selectedA2AReceiverOwnsObjective: true,
diff --git a/templates/content/server/plugins/db.ts b/templates/content/server/plugins/db.ts
index bc69a2a96f8..81348aa54f5 100644
--- a/templates/content/server/plugins/db.ts
+++ b/templates/content/server/plugins/db.ts
@@ -1081,6 +1081,29 @@ export const runContentMigrations = runMigrations(
CREATE INDEX IF NOT EXISTS document_edit_receipts_owner_document_idx
ON document_edit_receipts (owner_email, document_id)`,
},
+ {
+ version: 88,
+ name: "content-comment-ai-requests-and-actor",
+ sql: `ALTER TABLE document_comments ADD COLUMN IF NOT EXISTS actor_kind TEXT;
+ CREATE TABLE IF NOT EXISTS comment_ai_requests (
+ id TEXT PRIMARY KEY, owner_email TEXT NOT NULL, requester_email TEXT NOT NULL,
+ document_id TEXT NOT NULL, thread_id TEXT NOT NULL, root_comment_id TEXT NOT NULL,
+ field_id TEXT NOT NULL, intent TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'queued',
+ thread_digest TEXT NOT NULL, snapshot_json TEXT NOT NULL, base_revision TEXT NOT NULL,
+ suggestion_revision TEXT NOT NULL, run_id TEXT, agent_thread_id TEXT,
+ result_json TEXT, payload_json TEXT, error TEXT,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+ );
+ CREATE INDEX IF NOT EXISTS comment_ai_requests_document_requester_idx
+ ON comment_ai_requests (document_id, requester_email)`,
+ },
+ {
+ version: 89,
+ sql: `CREATE UNIQUE INDEX IF NOT EXISTS comment_ai_requests_active_thread_idx
+ ON comment_ai_requests (document_id, thread_id, requester_email)
+ WHERE status IN ('queued', 'running')`,
+ },
],
{ table: "content_migrations" },
);
diff --git a/templates/content/shared/comment-ai.ts b/templates/content/shared/comment-ai.ts
new file mode 100644
index 00000000000..970632f48b2
--- /dev/null
+++ b/templates/content/shared/comment-ai.ts
@@ -0,0 +1,37 @@
+export type CommentAiIntent = "suggest" | "reply" | "apply-resolve";
+
+export type CommentAiStatus =
+ | "queued"
+ | "running"
+ | "replied"
+ | "suggested"
+ | "resolved"
+ | "needs-review"
+ | "failed";
+
+export interface CommentAiRequest {
+ requestId: string;
+ documentId: string;
+ threadId: string;
+ rootCommentId: string;
+ intent: CommentAiIntent;
+ status: CommentAiStatus;
+ runId: string | null;
+ agentThreadId: string | null;
+ result: {
+ commentId?: string;
+ suggestionId?: string;
+ editApplied?: boolean;
+ resolved?: boolean;
+ } | null;
+ error: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface StartCommentAiResult extends CommentAiRequest {
+ dispatch: boolean;
+ actionScope: { kind: "content-comment-ai"; requestId: string };
+ prompt: string;
+ context?: string;
+}