Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
cfb3cd2
feat: add opt-in native mentions with passive agent attention
Maneek21 Oct 2, 2026
a569efc
fix: preserve note sharing identities and verify all native targets
Maneek21 Oct 2, 2026
c1a4155
test: certify upgraded schemas and close mention visibility gaps
Maneek21 Oct 2, 2026
662ecc2
fix: gate native chat dispatch while preserving legacy mentions
Maneek21 Oct 2, 2026
651c22a
fix: keep native mention popup within the viewport
Maneek21 Oct 2, 2026
9839d86
fix: preserve mention edits when navigating tasks
Maneek21 Oct 2, 2026
9063a30
fix: keep native mention picker inside the viewport
Maneek21 Oct 2, 2026
4754243
test: exercise mentions with the live workspace socket
Maneek21 Oct 2, 2026
8723491
test: navigate through the chat link with unread badges
Maneek21 Oct 2, 2026
4ff20d3
fix: publish mentions from already saved task descriptions
Maneek21 Oct 2, 2026
2f87d1c
refactor: scope native mentions to Chat Tasks and Knowledge
Maneek21 Oct 2, 2026
ce8f196
fix: refresh native task mentions outside React effects
Maneek21 Oct 2, 2026
fe3cc6c
fix: bound native mention parsing for untrusted content
Maneek21 Oct 2, 2026
8499389
fix: bound wiki mention token matching
Maneek21 Oct 2, 2026
14677ac
feat: expose native references to Defty and agent employees
Maneek21 Oct 2, 2026
04ede84
fix: initialize evidence path inside the CI runner
Maneek21 Oct 2, 2026
c66c65e
test: exercise live native reference workflows and clarify agent cont…
Maneek21 Oct 2, 2026
78ec8b1
fix: render agent-formatted native references without duplicate prefixes
Maneek21 Oct 2, 2026
4dd9f27
fix: reject unavailable agent references before approval and writes
Maneek21 Oct 2, 2026
638de7e
docs: teach native references at agent content write fields
Maneek21 Oct 2, 2026
a0cd499
fix: preserve unrelated MCP write handler registrations
Maneek21 Oct 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,7 @@ METRICS_SCRAPE_TOKEN=
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:admin@example.com

# Native mentions: enable after running the supported schema upgrade.
# References remain readable when publication is disabled.
DEFT_NATIVE_MENTIONS_ENABLED=false
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,15 @@ jobs:
diff --recursive --unified "$RUNNER_TEMP/upgrade-before" "$RUNNER_TEMP/upgrade-after"
- name: Run upgrade unit tests
run: pnpm test:upgrade
- name: Verify native mention behavior on the upgraded release schema
env:
DEFT_NATIVE_MENTION_CONCURRENCY_CERTIFY: 'true'
run: |
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -c "CREATE DATABASE deft_mentions_upgrade_test TEMPLATE deft_upgrade"
export DATABASE_URL=postgres://postgres:postgres@localhost:5432/deft_mentions_upgrade_test
export DEFT_TEST_DATABASE_URL="$DATABASE_URL"
pnpm --filter @deft/app-kit build
pnpm --filter @deft/api exec tsx --test test/native-mentions-db.test.ts test/native-mention-agents-db.test.ts test/native-mention-agent-catalog.test.ts

docker-build:
name: Production Image + Browser Smoke
Expand Down
83 changes: 83 additions & 0 deletions .github/workflows/native-mentions.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
name: Native Mentions
on:
pull_request:
branches: [master, main]
workflow_dispatch:
permissions:
contents: read
jobs:
native-mentions:
runs-on: ubuntu-latest
timeout-minutes: 25
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: deft_mentions_test
ports: ['5432:5432']
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s --health-timeout 5s --health-retries 10
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/deft_mentions_test
DEFT_TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/deft_mentions_test
JWT_SECRET: native-mention-ci-only-secret
JWT_REFRESH_SECRET: native-mention-ci-only-refresh
DEFT_NATIVE_MENTIONS_ENABLED: 'true'
DEFT_NATIVE_MENTION_CONCURRENCY_CERTIFY: 'true'
API_PORT: '4011'
NEXT_PUBLIC_APP_URL: http://localhost:4010
NEXT_PUBLIC_API_URL: http://localhost:4011
NEXT_PUBLIC_WS_URL: http://localhost:4011
DEFT_WEB_URL: http://localhost:4010
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6.1.0
with:
version: 11.10.0
- uses: actions/setup-node@v7
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @deft/app-kit build
- run: pnpm exec playwright install --with-deps chromium
- name: Fresh schema and concurrent publication/delivery
run: |
pnpm db:push-full
export DEFT_MENTION_EVIDENCE_DIR="$RUNNER_TEMP/native-mention-evidence"
mkdir -p "$DEFT_MENTION_EVIDENCE_DIR"
set -o pipefail
pnpm --filter @deft/api exec tsx --test test/native-mentions-db.test.ts test/native-mention-agents-db.test.ts test/native-mention-agent-catalog.test.ts | tee "$DEFT_MENTION_EVIDENCE_DIR/agent-and-native-tests.log"
- name: Separate browser fixture database
run: |
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -c "CREATE DATABASE deft_mentions_browser_test"
echo "DATABASE_URL=postgres://postgres:postgres@localhost:5432/deft_mentions_browser_test" >> "$GITHUB_ENV"
echo "DEFT_TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/deft_mentions_browser_test" >> "$GITHUB_ENV"
echo "DEFT_MENTION_FIXTURE_PATH=$RUNNER_TEMP/native-mention-fixture.json" >> "$GITHUB_ENV"
echo "DEFT_MENTION_EVIDENCE_DIR=$RUNNER_TEMP/native-mention-evidence" >> "$GITHUB_ENV"
- name: Run real desktop, recipient and mobile journeys
run: |
set -euo pipefail
pnpm db:push-full
pnpm --filter @deft/api exec tsx test/fixtures/seed-native-mention-evidence.ts
mkdir -p "$DEFT_MENTION_EVIDENCE_DIR"
pnpm --filter @deft/api exec tsx src/server.ts > "$DEFT_MENTION_EVIDENCE_DIR/api.log" 2>&1 &
api_pid=$!
pnpm --filter @deft/web exec next dev --port 4010 > "$DEFT_MENTION_EVIDENCE_DIR/web.log" 2>&1 &
web_pid=$!
trap 'kill "$api_pid" "$web_pid" 2>/dev/null || true' EXIT
for attempt in $(seq 1 90); do
if curl -fsS http://localhost:4011/health >/dev/null && curl -fsS http://localhost:4010/login >/dev/null; then break; fi
sleep 1
done
node scripts/native-mention-browser-evidence.mjs
- name: Retain screenshots, recordings and diagnostics
if: always()
uses: actions/upload-artifact@v7
with:
name: native-mention-evidence
path: ${{ runner.temp }}/native-mention-evidence
if-no-files-found: warn
2 changes: 2 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { teamRoutes } from './routes/teams.js';
import { emojiRoutes } from './routes/emoji.js';
import { workflowRoutes } from './routes/workflows.js';
import { crossReferenceRoutes } from './routes/cross-references.js';
import { nativeMentionRoutes } from './routes/native-mentions.js';
import { auditRoutes } from './routes/audit.js';
import { decisionRoutes } from './routes/decisions.js';
import { managerRoutes } from './routes/manager.js';
Expand Down Expand Up @@ -222,6 +223,7 @@ app.route('/api/teams', teamRoutes);
app.route('/api/emoji', emojiRoutes);
app.route('/api/workflows', workflowRoutes);
app.route('/api', crossReferenceRoutes);
app.route('/api/native-mentions', nativeMentionRoutes);
app.route('/api', moduleTaskLinkRoutes);
app.route('/api/audit', auditRoutes);
app.route('/api/decisions', decisionRoutes);
Expand Down
8 changes: 8 additions & 0 deletions apps/api/src/lib/agent-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
isAgentToolDisabled,
} from './agent-tool-policy.js';
import { getApprovalTier, shouldAutoExecute } from './agent-approval.js';
import { validateNativeAgentMentionWrite } from './native-mention-agent-writes.js';
import {
MODULE_OPERATION_REQUEST_SCHEMAS,
ModuleIdSchema,
Expand Down Expand Up @@ -1451,6 +1452,10 @@ export async function executeAction(
): Promise<{ success: boolean; result: any; error?: string }> {
const agentEmployeeId = options?.agentEmployeeId ?? null;
try {
if (!options?.trustedHumanMcpPrincipal) {
const referenceError = await validateNativeAgentMentionWrite(action, params, orgId, userId, agentEmployeeId ?? undefined);
if (referenceError) return { success: false, result: null, error: referenceError };
}
const taskScopeError = await employeeTaskWriteScopeError(
action,
params,
Expand Down Expand Up @@ -3664,6 +3669,9 @@ async function executeActionDirectLocked(
params = normalizeAgentModuleTaskLinkParams(action, params) as Record<string, any>;
if (humanPrincipal) params = { ...params, [HUMAN_MCP_PRINCIPAL_KEY]: humanPrincipal };

const referenceError = await validateNativeAgentMentionWrite(action, params, orgId, userId, options?.agentEmployeeId);
if (referenceError) throw new Error(referenceError);

const taskScopeError = await employeeTaskWriteScopeError(
action,
params,
Expand Down
11 changes: 11 additions & 0 deletions apps/api/src/lib/agent-context.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { executeNativeMentionRuntimeTool } from './native-mention-runtime-tools.js';
import { NATIVE_MENTION_AGENT_TOOL_SCHEMAS } from './native-mention-agent-contract.js';
import { executeModuleReadOperation, isModuleReadOperation } from './module-read-operations.js';
import { loadAuthorizedAppDiscovery } from './app-discovery.js';
import { db } from './db.js';
Expand Down Expand Up @@ -105,6 +107,15 @@ export async function executeToolCall(
const policyError = await agentToolPolicyError(orgId, agentEmployeeId, toolName);
if (policyError) return { result: { error: policyError }, citations: [] };

if (NATIVE_MENTION_AGENT_TOOL_SCHEMAS.some(tool => tool.name === toolName)) {
const result = await executeNativeMentionRuntimeTool(toolName, params, orgId, _userId, agentEmployeeId);
const items = 'items' in result && Array.isArray(result.items) ? result.items : [];
const citations = items.filter(item => item.state === 'available' && item.href).map(item => ({
type: item.ref.resource_type, id: item.ref.resource_id, title: item.label!, url: item.href!,
}));
return { result, citations };
}

// App operations already own approval, replay, budget, and receipt policy
// through App Runs. Keep this adapter ahead of the generic native-agent
// daily-action gate so an App request is never charged or reviewed twice.
Expand Down
24 changes: 16 additions & 8 deletions apps/api/src/lib/agent-mention-normalization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export type AgentMentionIdentity = {
name: string;
slug: string;
};
import { stripNativeMentionAtoms } from '@deft/shared';

export type PlainAgentMentionResolution = {
content: string;
Expand Down Expand Up @@ -42,15 +43,19 @@ export function normalizePlainAgentMentions(
}
}

let normalized = content;
const segments = content.split(/(<span\b[^>]*data-deft-ref-kind[^>]*>[\s\S]*?<\/span>|\[\[deft:(?:person|task|wiki_page):[^\]]+\]\])/gi);
let normalized = segments;
const resolvedUserIds = new Set<string>();
const ambiguousAliases = new Set<string>();
const aliases = Array.from(ownersByAlias.keys()).sort((a, b) => b.length - a.length);

for (const alias of aliases) {
const aliasPattern = escapeRegex(alias).replace(/\\ /g, '\\s+');
const pattern = new RegExp(`(^|[^a-z0-9_])@(${aliasPattern})(?=$|[^a-z0-9_-])`, 'gi');
if (!pattern.test(normalized)) continue;
if (!normalized.some(segment => {
pattern.lastIndex = 0;
return stripNativeMentionAtoms(segment) !== '' && pattern.test(segment);
})) continue;
pattern.lastIndex = 0;

const owners = ownersByAlias.get(alias) ?? [];
Expand All @@ -60,15 +65,18 @@ export function normalizePlainAgentMentions(
}

const agent = owners[0]!;
normalized = normalized.replace(
pattern,
(_match, prefix) => `${prefix}<@${agent.userId}|${agent.name}>`,
);
resolvedUserIds.add(agent.userId);
normalized = normalized.map(segment => {
if (stripNativeMentionAtoms(segment) === '') return segment;
pattern.lastIndex = 0;
return segment.replace(pattern, (_match, prefix) => {
resolvedUserIds.add(agent.userId);
return `${prefix}<@${agent.userId}|${agent.name}>`;
});
});
}

return {
content: normalized,
content: normalized.join(''),
resolvedUserIds: Array.from(resolvedUserIds),
ambiguousAliases: Array.from(ambiguousAliases),
};
Expand Down
9 changes: 9 additions & 0 deletions apps/api/src/lib/agent-runner.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { NATIVE_MENTION_AGENT_GUIDANCE } from './native-mention-agent-contract.js';
import { nativeMentionsEnabled } from './native-mentions.js';
import { validateNativeAgentMentionWrite } from './native-mention-agent-writes.js';
// Reusable agent reasoning engine — used by @agent mentions in chat and other background jobs.
// Supports two modes:
// 'chat_mention' (default): write actions are skipped (safety for @mentions)
Expand Down Expand Up @@ -367,6 +370,7 @@ export async function runAgentQuery(params: {
}

systemPrompt = ensureImmutablePlatformPolicy(systemPrompt);
if (nativeMentionsEnabled()) systemPrompt += '\n\n' + NATIVE_MENTION_AGENT_GUIDANCE;
systemPrompt += '\nTool responses include result and sources. Use the exact local URLs in sources as Markdown links; never invent a host. A failed tool call is not evidence that records are absent. Inspect the module schema, use module_record_incoming for incoming relations and module_record_latest_related for declared latest summaries. Use the read-only module_record_task_links tool for linked task states.';
if (readOnlyRequest) systemPrompt += '\nThis request is read-only. Do not propose or execute writes.';

Expand Down Expand Up @@ -675,6 +679,11 @@ export async function runAgentQuery(params: {
const isAction = allActionTools.has(tool.name);

if (isAction) {
const referenceError = await validateNativeAgentMentionWrite(tool.name, tool.input as Record<string, unknown>, orgId, userId, params.agentEmployeeId);
if (referenceError) {
toolResults.push({ type: 'tool_result', tool_use_id: tool.id, is_error: true, content: JSON.stringify({ error: referenceError }) });
continue;
}
const approvalTier = getApprovalTier(tool.name, actionApprovalTiers.get(tool.name));
if (mode === 'background' && shouldAutoExecute(tool.name, trustLevel, tool.input, approvalTier)) {
// Background mode: auto-execute if trust level permits
Expand Down
14 changes: 9 additions & 5 deletions apps/api/src/lib/agent-tools.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { NATIVE_MENTION_AGENT_TOOL_SCHEMAS, NATIVE_MENTION_CONTENT_GUIDANCE } from './native-mention-agent-contract.js';
import { MODULE_OPERATION_DESCRIPTIONS } from './module-tool-descriptions.js';
import type Anthropic from '@anthropic-ai/sdk';
import {
Expand Down Expand Up @@ -57,6 +58,9 @@ export const APP_ACTION_AGENT_TOOLS: Anthropic.Tool[] = APP_ACTION_OPERATION_NAM
);

export const AGENT_TOOLS: Anthropic.Tool[] = [
...NATIVE_MENTION_AGENT_TOOL_SCHEMAS.map(tool => ({
name: tool.name, description: tool.description, input_schema: tool.inputSchema as Anthropic.Tool['input_schema'],
})),
{
name: 'search_messages',
description:
Expand Down Expand Up @@ -136,7 +140,7 @@ export const AGENT_TOOLS: Anthropic.Tool[] = [
},
assignee_name: { type: 'string', description: 'Assignee name' },
due_date: { type: 'string', description: 'Due date in YYYY-MM-DD format' },
description: { type: 'string', description: 'Task description' },
description: { type: 'string', description: 'Task description. ' + NATIVE_MENTION_CONTENT_GUIDANCE },
subtasks: {
type: 'array',
description:
Expand All @@ -145,7 +149,7 @@ export const AGENT_TOOLS: Anthropic.Tool[] = [
type: 'object',
properties: {
title: { type: 'string', description: 'Subtask title' },
description: { type: 'string', description: 'Optional subtask description' },
description: { type: 'string', description: 'Optional subtask description. ' + NATIVE_MENTION_CONTENT_GUIDANCE },
assignee_name: { type: 'string', description: 'Optional subtask assignee name' },
due_date: { type: 'string', description: 'Optional due date in YYYY-MM-DD format' },
priority: {
Expand Down Expand Up @@ -239,7 +243,7 @@ export const AGENT_TOOLS: Anthropic.Tool[] = [
type: 'object' as const,
properties: {
task_identifier: { type: 'string', description: 'Task ID like DEFT-5 or the task UUID' },
content: { type: 'string', description: 'Comment body (markdown)' },
content: { type: 'string', description: 'Comment body (markdown). ' + NATIVE_MENTION_CONTENT_GUIDANCE },
},
required: ['task_identifier', 'content'],
},
Expand Down Expand Up @@ -372,7 +376,7 @@ export const AGENT_TOOLS: Anthropic.Tool[] = [
type: 'string',
description: 'Name of the space (e.g., "general", "engineering")',
},
content: { type: 'string', description: 'Message content' },
content: { type: 'string', description: 'Message content. ' + NATIVE_MENTION_CONTENT_GUIDANCE },
},
required: ['space_name', 'content'],
},
Expand Down Expand Up @@ -643,7 +647,7 @@ export const AGENT_TOOLS: Anthropic.Tool[] = [
properties: {
slug: { type: 'string', description: 'Optional: slug of existing page to update. Omit to create new.' },
title: { type: 'string', description: 'Page title (required for new pages)' },
content: { type: 'string', description: 'Page content in markdown' },
content: { type: 'string', description: 'Page content in markdown. ' + NATIVE_MENTION_CONTENT_GUIDANCE },
type: { type: 'string', enum: ['concept', 'entity', 'decision', 'resource', 'procedure', 'preference', 'fact'], description: 'Page type (required for new pages)' },
summary: { type: 'string', description: 'One-sentence summary' },
related_slugs: {
Expand Down
22 changes: 21 additions & 1 deletion apps/api/src/lib/attention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
isModuleWriteActionName,
} from './module-action-visibility.js';
import { scheduleAttentionDeliveries, scheduleAttentionDelivery } from './web-push.js';
import { nativeDeliveryAccessSql } from './native-mention-visibility.js';

export type AttentionLane = 'needs_you' | 'updates';
export type AttentionPriority = 'critical' | 'high' | 'normal' | 'low';
Expand Down Expand Up @@ -55,7 +56,9 @@ export type AttentionDraft = {

export function visibleAttentionCondition(userId: string) {
return sql<boolean>`(
${attentionItems.source_type} NOT IN ('message', 'space', 'agent_action')
${attentionItems.source_type} NOT IN ('message', 'space', 'agent_action', 'native_mention')
OR (${attentionItems.source_type} = 'native_mention'
AND ${nativeDeliveryAccessSql(userId, sql`${attentionItems.source_id}`, sql`${attentionItems.org_id}`)})
OR (
${attentionItems.source_type} = 'message'
AND EXISTS (
Expand Down Expand Up @@ -151,6 +154,15 @@ function sourceFromLink(link: string | null): { messageId: string | null; spaceI

export function notificationToAttentionDraft(notification: LegacyNotification): AttentionDraft {
const metadata = objectMetadata(notification.metadata);
const nativeDeliveryId = metadataString(metadata, 'native_mention_delivery_id');
if (nativeDeliveryId) return {
orgId: notification.org_id, userId: notification.user_id, kind: 'mention',
lane: 'needs_you', priority: 'normal', dedupeKey: `native-mention:${nativeDeliveryId}`,
sourceType: 'native_mention', sourceId: nativeDeliveryId,
sourceEventId: `native-mention:${nativeDeliveryId}`, title: notification.title,
body: notification.body, link: notification.link, metadata,
occurredAt: notification.created_at,
};
const linkedSource = sourceFromLink(notification.link);
const taskId = metadataString(metadata, 'task_id', 'taskId');
const messageId = metadataString(metadata, 'message_id', 'messageId', 'source_message_id') ?? linkedSource.messageId;
Expand Down Expand Up @@ -567,6 +579,12 @@ export async function filterVisibleAttentionItems<T extends typeof attentionItem
userId: string,
rows: T[],
): Promise<T[]> {
const nativeIds = rows.filter(item => item.source_type === 'native_mention').map(item => item.id);
const visibleNative = nativeIds.length ? await db.select({ id: attentionItems.id }).from(attentionItems).where(and(
inArray(attentionItems.id, nativeIds),
nativeDeliveryAccessSql(userId, sql`${attentionItems.source_id}`, sql`${attentionItems.org_id}`),
)) : [];
const allowedNative = new Set(visibleNative.map(item => item.id));
const messageIds = rows.filter((item) => item.source_type === 'message').map((item) => item.source_id);
const spaceIds = rows.filter((item) => item.source_type === 'space').map((item) => item.source_id);
const actionIds = rows.filter((item) => item.source_type === 'agent_action').map((item) => item.source_id);
Expand Down Expand Up @@ -607,6 +625,8 @@ export async function filterVisibleAttentionItems<T extends typeof attentionItem
const allowedSpaces = new Set(visibleSpaces.filter((row) => row.type === 'public' || row.member_id).map((row) => row.id));
const allowedActions = new Set(visibleActions.map((row) => row.id));
const inaccessible = rows.filter((item) =>
(item.source_type === 'native_mention' && !allowedNative.has(item.id))
||
(item.source_type === 'message' && !allowedMessages.has(item.source_id))
|| (item.source_type === 'space' && !allowedSpaces.has(item.source_id))
|| (item.source_type === 'agent_action' && !allowedActions.has(item.source_id)));
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/lib/mcp-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ export const EMPLOYEE_MCP_APP_SCOPES = [
] as const;

export const EMPLOYEE_MCP_RESOURCE_SCOPES = [
'read:workspace',
'write:workspace',
'read:messages',
'read:wiki',
'read:tasks',
'write:tasks',
'write:modules',
Expand Down
Loading
Loading