Skip to content
5 changes: 5 additions & 0 deletions .changeset/comment-receipt-navigation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-native/core": patch
---

Allow app routing for safe inline Markdown links so comment suggestion receipts preserve the active workspace.
5 changes: 5 additions & 0 deletions .changeset/comment-suggestion-transaction-reads.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/scoped-action-context.md
Original file line number Diff line number Diff line change
@@ -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.
151 changes: 147 additions & 4 deletions packages/core/src/agent/production-agent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -1641,6 +1642,7 @@ describe("createProductionAgentHandler", () => {
async *stream(opts): AsyncIterable<EngineEvent> {
lifecycle.push("stream");
seenTools.push(opts.tools.map((tool) => tool.name));
seenScopes.push(getRequestRunContext()?.actionScope);
yield {
type: "assistant-content",
parts: [{ type: "text", text: "done" }],
Expand All @@ -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(
Expand All @@ -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",
},
}),
}),
);
Expand All @@ -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<EngineEvent> {
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 = {
Expand Down Expand Up @@ -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",
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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(
{
Expand Down
Loading
Loading