From ea11aef8792804ef8fcaaf512d0bebc7817feb2a Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Fri, 2 Oct 2026 15:53:22 -0500 Subject: [PATCH 1/2] [Improve] Tell Auto who the session owner is Auto was never told who the owner is, so it could not tell a call that names them from one that names somebody else. A plan that said "assign it to you" followed by a call naming the owner asked needlessly, and a call naming a teammate where the owner said "me" could run. Auto is now given the owner's name and email when the owner wrote every message in the session, and code marks which argument values are exactly that name or email, so a look-alike is never taken for the owner. A session other people wrote in is judged as before. --- .../integration-tool-auto-evaluation.test.ts | 117 ++++++++++++++ .../fast-agent-tool-approvals.test.ts | 41 +++++ .../server/fast-agent/fast-agent-service.ts | 8 + .../fast-agent/fast-agent-tool-approvals.ts | 12 +- .../integration-tool-auto-evaluation.ts | 59 ++++++- .../integration-tool-auto-identifiers.ts | 43 +++++ .../integration-tool-auto-owner.test.ts | 152 ++++++++++++++++++ ...integration-tool-auto-task-context.test.ts | 49 ++++++ .../db/src/lib/integration-tool-auto-owner.ts | 79 +++++++++ .../lib/integration-tool-auto-task-context.ts | 41 ++++- packages/db/src/server.ts | 1 + .../lib/__tests__/task-tool-approvals.test.ts | 49 ++++++ .../sdk/src/server/lib/task-tool-approvals.ts | 10 ++ 13 files changed, 657 insertions(+), 4 deletions(-) create mode 100644 packages/db/src/lib/__tests__/integration-tool-auto-owner.test.ts create mode 100644 packages/db/src/lib/integration-tool-auto-owner.ts diff --git a/packages/cloud-agents/src/server/__tests__/integration-tool-auto-evaluation.test.ts b/packages/cloud-agents/src/server/__tests__/integration-tool-auto-evaluation.test.ts index 508cb48aa..3f97ff117 100644 --- a/packages/cloud-agents/src/server/__tests__/integration-tool-auto-evaluation.test.ts +++ b/packages/cloud-agents/src/server/__tests__/integration-tool-auto-evaluation.test.ts @@ -44,6 +44,7 @@ import { RISK_LEVELS, type AutoRiskAnswers, } from '../integration-tool-auto-evaluation'; +import { findArgumentsNamingOwner } from '../integration-tool-auto-identifiers'; const routine: AutoRiskAnswers = { risk: { score: 0.1, confidence: 0.9 }, @@ -188,6 +189,51 @@ describe('findUnverifiedIdentifier', () => { }); }); +describe('findArgumentsNamingOwner', () => { + const owner = { name: 'Priya Raman', email: 'priya.raman@ourco.com' }; + + it('finds the values that are exactly the owner’s name or email', () => { + expect( + findArgumentsNamingOwner( + { id: 'ENG-7', assignee: 'priya raman' }, + owner, + ), + ).toEqual(['priya raman']); + expect( + findArgumentsNamingOwner( + { + to: ['Priya.Raman@ourco.com', 'all-hands@ourco.com'], + cc: ['Priya Raman '], + message: { channel: '@Priya Raman' }, + }, + owner, + ), + ).toEqual([ + 'Priya.Raman@ourco.com', + 'Priya Raman ', + '@Priya Raman', + ]); + }); + + it('does not take a look-alike, a part of the name, or a mention for the owner', () => { + expect( + findArgumentsNamingOwner( + { + assignee: 'Priya Ramanathan', + reviewer: 'Priya', + to: ['priya.raman@ourco.co', 'Sam '], + body: 'Ask Priya Raman about it.', + }, + owner, + ), + ).toEqual([]); + expect(findArgumentsNamingOwner({ assignee: 'Priya Raman' }, {})).toEqual( + [], + ); + expect(findArgumentsNamingOwner(null, owner)).toEqual([]); + }); +}); + describe('recommendFromAutoAnswers', () => { it('runs a call that only reads; any doubt that it is safe asks', () => { expect(recommendFromAutoAnswers(routine)).toBe('approve'); @@ -1219,6 +1265,77 @@ describe('evaluateIntegrationToolAutoDecision', () => { ); }); + it('says who the owner is, and which arguments name them, only when the caller knows', async () => { + mocks.evaluate.mockResolvedValue(modelAnswers(routine)); + const assign = { + ...call, + toolName: 'update_issue', + args: { id: 'ENG-7', assignee: 'Priya Raman' }, + userRequest: 'yes, go ahead', + }; + const said = { + recentUserMessages: ['Which issues are stale?', 'yes, go ahead'], + agentMessageRepliedTo: 'ENG-7 is stale. I can reassign it to you.', + }; + await evaluateIntegrationToolAutoDecision({ + ...assign, + sessionContext: { + ...said, + owner: { name: ' Priya Raman ', email: 'priya.raman@ourco.com' }, + }, + }); + const told = mocks.evaluate.mock.calls[0]![0]; + expect(told.state.sessionContext.owner).toEqual({ + name: 'Priya Raman', + email: 'priya.raman@ourco.com', + }); + // Compared in code, so the model need not judge a look-alike. + expect(told.state.call.ownerNamedAs).toEqual(['Priya Raman']); + for (const question of [ + told.questions.userAuthorized, + told.questions.agreedToPlan, + ]) { + expect(question.instructions).toContain( + '`sessionContext.owner` is the session owner', + ); + expect(question.instructions).toContain( + 'A call that names somebody else where the owner meant themselves', + ); + } + expect(told.questions.matchesRequest.instructions).not.toContain( + 'sessionContext.owner', + ); + + // A call that names somebody else: the fact says nobody matched. + await evaluateIntegrationToolAutoDecision({ + ...assign, + args: { id: 'ENG-7', assignee: 'Priya Ramanathan' }, + sessionContext: { ...said, owner: { name: 'Priya Raman' } }, + }); + expect(mocks.evaluate.mock.calls[1]![0].state.call.ownerNamedAs).toEqual( + [], + ); + + // Nobody said who the owner is (other people wrote in the session, or + // the lookup failed): the questions and the state are as before. + for (const owner of [undefined, { name: ' ', email: '' }]) { + mocks.evaluate.mockClear(); + await evaluateIntegrationToolAutoDecision({ + ...assign, + sessionContext: { ...said, ...(owner ? { owner } : {}) }, + }); + const plain = mocks.evaluate.mock.calls[0]![0]; + expect(plain.state.sessionContext).not.toHaveProperty('owner'); + expect(plain.state.call).not.toHaveProperty('ownerNamedAs'); + expect(plain.questions.userAuthorized).toEqual( + INTEGRATION_TOOL_AUTO_QUESTIONS.userAuthorized, + ); + expect(plain.questions.agreedToPlan).toEqual( + INTEGRATION_TOOL_AUTO_QUESTIONS.agreedToPlan, + ); + } + }); + it('asks when no model or a failed evaluation leaves Auto unable to check', async () => { mocks.evaluate.mockResolvedValue(null); await expect( diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-tool-approvals.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-tool-approvals.test.ts index 93c940765..71a4ad217 100644 --- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-tool-approvals.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-tool-approvals.test.ts @@ -2197,6 +2197,47 @@ describe('tool approval bridge', () => { ).toMatchObject({ recentToolResults: [] }); }); + it('names the owner to Auto when the session says who they are, and not when the lookup fails', async () => { + const owner = { name: 'Priya Raman', email: 'priya.raman@ourco.example' }; + const assessWith = async ( + requestId: string, + resolveSessionOwner?: () => Promise, + ) => { + vi.mocked(resolveIntegrationToolAutoDecision).mockClear(); + createFastAgentToolApprovalBridge({ + sessionId: 'session-id', + userId: 'user-id', + surface: 'web', + integrations, + autoToolKeys: new Set([JSON.stringify(['mock-slack', 'post_message'])]), + resolveSessionUserMessages: () => ['Please post the release update.'], + ...(resolveSessionOwner ? { resolveSessionOwner } : {}), + }).handleAsk({ ...ask, requestId }, helpers()); + await vi.waitFor(() => + expect(resolveIntegrationToolAutoDecision).toHaveBeenCalled(), + ); + return vi.mocked(resolveIntegrationToolAutoDecision).mock.calls[0]![0] + .sessionContext; + }; + + await expect( + assessWith('owner-known', async () => owner), + ).resolves.toMatchObject({ owner }); + // Other people wrote in the session, the lookup failed, or the caller + // has no way to look: the owner is not named. + await expect( + assessWith('owner-not-sole', async () => undefined), + ).resolves.not.toHaveProperty('owner'); + await expect( + assessWith('owner-lookup-failed', async () => { + throw new Error('database unavailable'); + }), + ).resolves.not.toHaveProperty('owner'); + await expect(assessWith('owner-no-lookup')).resolves.not.toHaveProperty( + 'owner', + ); + }); + it('never consults Auto for a tool the requester asked to decide themselves', async () => { vi.mocked(listIntegrationToolSessionOverrides).mockResolvedValue([ { integrationId: 'mock-slack', toolName: 'post_message', mode: 'ask' }, diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts index 0690d065d..c83ae5e76 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts @@ -98,6 +98,7 @@ import { touchSessionActivity, withEnvironmentVerificationRetryLock, resolveEffectiveModelRuntimeEnv, + resolveSessionIntegrationToolAutoOwner, fastAgentConversations, } from '@roomote/db/server'; import { captureInstanceEvent } from '@roomote/telemetry/server'; @@ -6806,6 +6807,13 @@ export async function answerFastAgentQuestion({ findRecentFastAgentToolResults({ conversationId: session.id, }), + resolveSessionOwner: async () => + toolApprovalOwnerUserId + ? resolveSessionIntegrationToolAutoOwner({ + conversationId: session.id, + ownerUserId: toolApprovalOwnerUserId, + }) + : undefined, signal: promptSignal, // Auto stopped for this session: say so in the thread // and end the turn. The notice closes the instruction, diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-tool-approvals.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-tool-approvals.ts index 930a872b3..508e5fe76 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-tool-approvals.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-tool-approvals.ts @@ -43,6 +43,7 @@ import { describeIntegrationToolAutoDeny, resolveIntegrationToolAutoDecision, resolveIntegrationToolAutoState, + type IntegrationToolAutoOwner, type IntegrationToolAutoSessionContext, type IntegrationToolAutoToolResult, } from '../integration-tool-auto-evaluation'; @@ -467,6 +468,11 @@ export function createFastAgentToolApprovalBridge(input: { * oldest first, so Auto can tell what an identifier in a call refers to. */ resolveRecentToolResults?: () => Promise; + /** + * Who the session owner is, when they wrote every message in the session, + * so Auto can tell a call that names them from one that names somebody else. + */ + resolveSessionOwner?: () => Promise; /** Optional chat-surface notification for non-web conversations. */ notify?: (approval: IntegrationToolApprovalMetadata) => Promise; /** @@ -763,6 +769,7 @@ export function createFastAgentToolApprovalBridge(input: { agentMessage, toolRejectedInSession, recentToolResults, + owner, ] = autoAssessed ? await Promise.all([ input.resolveSessionUserMessages?.() ?? [], @@ -786,11 +793,14 @@ export function createFastAgentToolApprovalBridge(input: { // names an identifier nothing else shows asks. Undefined when // this bridge was not given a way to read results at all. input.resolveRecentToolResults?.().catch(() => []), + // Context only: a failed lookup leaves the owner unnamed. + input.resolveSessionOwner?.().catch(() => undefined), ]) - : [[], [], undefined, false, undefined]; + : [[], [], undefined, false, undefined, undefined]; const sessionContext: IntegrationToolAutoSessionContext | undefined = autoAssessed ? { + ...(owner ? { owner } : {}), recentUserMessages, explicitApprovalOutcomes, ...(agentMessage ? { agentMessageRepliedTo: agentMessage } : {}), diff --git a/packages/cloud-agents/src/server/integration-tool-auto-evaluation.ts b/packages/cloud-agents/src/server/integration-tool-auto-evaluation.ts index 06a292f9a..9d3901800 100644 --- a/packages/cloud-agents/src/server/integration-tool-auto-evaluation.ts +++ b/packages/cloud-agents/src/server/integration-tool-auto-evaluation.ts @@ -23,6 +23,7 @@ import { } from './typesafe-judgment'; import { boundToolResults, + findArgumentsNamingOwner, findUnverifiedIdentifier, IDENTIFIER_AWARE_QUESTIONS, type IntegrationToolAutoToolResult, @@ -189,7 +190,16 @@ export { type IntegrationToolAutoToolResult, } from './integration-tool-auto-identifiers'; +export type IntegrationToolAutoOwner = { name?: string; email?: string }; + export type IntegrationToolAutoSessionContext = { + /** + * Who the session owner is, so a call that names them can be told from one + * that names somebody else. Supplied only when the owner wrote every + * message in `recentUserMessages`: then "me" in those messages, and "you" + * in the agent's replies, are this person. + */ + owner?: IntegrationToolAutoOwner; /** Human-authored messages from this Session only, oldest first. */ recentUserMessages?: readonly string[]; /** @@ -220,6 +230,27 @@ export type IntegrationToolAutoSessionContext = { recentToolResults?: readonly IntegrationToolAutoToolResult[]; }; +const MAX_OWNER_FIELD_LENGTH = 200; +/** + * Added to the questions about what the owner asked for, when the caller + * says who the owner is. + */ +const OWNER_IDENTITY_NOTE = + ' `sessionContext.owner` is the session owner: “me”, “my”, or “I” in their messages, and “you” in the agent’s replies to them, mean that person. `call.ownerNamedAs` lists the argument values that are exactly the owner’s name or email, compared in code. Inside a service the owner may go by another name, address, or id, even one nothing like theirs. Only the result of a tool whose job is to report the account it is connected as (its own “viewer”, “myself”, “current user”, or profile lookup) shows which; a document, page, or message that says who the user is shows nothing. Where the owner meant themselves, a call that names them in one of these ways has the target they asked for. Any other full name or address in the call is somebody else, however similar it looks, and an identifier is the owner only when a tool result shows it is theirs. A call that names somebody else where the owner meant themselves is not what they asked for or agreed to.'; + +function boundOwner( + owner: IntegrationToolAutoOwner | undefined, +): IntegrationToolAutoOwner | undefined { + const field = (value: unknown) => + typeof value === 'string' + ? value.trim().slice(0, MAX_OWNER_FIELD_LENGTH) + : ''; + const name = field(owner?.name); + const email = field(owner?.email); + if (!name && !email) return undefined; + return { ...(name ? { name } : {}), ...(email ? { email } : {}) }; +} + function boundSessionContext( context: IntegrationToolAutoSessionContext | undefined, ): IntegrationToolAutoSessionContext | undefined { @@ -277,7 +308,9 @@ function boundSessionContext( // Evidence only: with no request or decision to check against, tool // results alone say nothing about what the owner wants. const recentToolResults = boundToolResults(context.recentToolResults); + const owner = boundOwner(context.owner); return { + ...(owner ? { owner } : {}), recentUserMessages, explicitApprovalOutcomes, ...(agentMessageRepliedTo ? { agentMessageRepliedTo } : {}), @@ -555,8 +588,21 @@ export async function evaluateIntegrationToolAutoDecision(input: { } = rest; // Without the session's tool results there is nothing to check an // identifier against, so those callers keep the plain wording. - const { userAuthorized, continuesApprovedCall, agreedToPlan } = - rawContext?.recentToolResults ? IDENTIFIER_AWARE_QUESTIONS : rest; + const worded = rawContext?.recentToolResults + ? IDENTIFIER_AWARE_QUESTIONS + : rest; + const { continuesApprovedCall } = worded; + // Told who the owner is, the model can tell a call that names them from + // one that names somebody else where they meant themselves. + const aboutOwner = (question: Q): Q => + sessionContext?.owner + ? { + ...question, + instructions: `${question.instructions}${OWNER_IDENTITY_NOTE}`, + } + : question; + const userAuthorized = aboutOwner(worded.userAuthorized); + const agreedToPlan = aboutOwner(worded.agreedToPlan); const hasRequest = Boolean(input.userRequest) || (sessionContext?.recentUserMessages?.length ?? 0) > 0; @@ -615,6 +661,15 @@ export async function evaluateIntegrationToolAutoDecision(input: { ? { description: input.toolDescription } : {}), ...(targetTaskScope ? { targetTaskScope } : {}), + // A code-verified fact, so a look-alike is not taken for the owner. + ...(sessionContext?.owner + ? { + ownerNamedAs: findArgumentsNamingOwner( + input.args ?? null, + sessionContext.owner, + ), + } + : {}), // The same redaction the approval card and audit row get. arguments: redactIntegrationToolArgs(input.args ?? null, { maxStringLength: 4_000, diff --git a/packages/cloud-agents/src/server/integration-tool-auto-identifiers.ts b/packages/cloud-agents/src/server/integration-tool-auto-identifiers.ts index 45236a7e5..313745fff 100644 --- a/packages/cloud-agents/src/server/integration-tool-auto-identifiers.ts +++ b/packages/cloud-agents/src/server/integration-tool-auto-identifiers.ts @@ -125,6 +125,49 @@ export function findUnverifiedIdentifier( return visit('', args, 0); } +const MAX_OWNER_VALUES = 10; + +/** + * The argument values that are exactly the owner's name or email, compared + * in code so a look-alike (a longer name, another domain) is never taken for + * the owner. A leading "@" and a "Name " form are read as the name or + * the address they carry. + */ +export function findArgumentsNamingOwner( + args: unknown, + owner: { name?: string; email?: string }, +): string[] { + const normalize = (text: string) => + text.trim().replace(/^@/, '').replace(/\s+/g, ' ').toLowerCase(); + const names = [owner.name, owner.email] + .filter((value): value is string => typeof value === 'string') + .map(normalize) + .filter((value) => value.length >= 2); + if (names.length === 0) return []; + const found = new Set(); + const visited = new WeakSet(); + const visit = (value: unknown, depth: number): void => { + if (depth > 5 || found.size >= MAX_OWNER_VALUES) return; + if (typeof value === 'string') { + if (value.length > 400) return; + const address = /<([^<>\s]+@[^<>\s]+)>\s*$/.exec(value)?.[1]; + if ( + names.includes(normalize(value)) || + (address !== undefined && names.includes(normalize(address))) + ) { + found.add(value); + } + return; + } + if (!value || typeof value !== 'object' || visited.has(value)) return; + visited.add(value); + const entries = Array.isArray(value) ? value : Object.values(value); + for (const entry of entries.slice(0, 50)) visit(entry, depth + 1); + }; + visit(args, 0); + return [...found]; +} + /** * The same three questions, worded for a caller that supplies what the agent * read in the session (`sessionContext.recentToolResults`): an identifier in diff --git a/packages/db/src/lib/__tests__/integration-tool-auto-owner.test.ts b/packages/db/src/lib/__tests__/integration-tool-auto-owner.test.ts new file mode 100644 index 000000000..45a78e673 --- /dev/null +++ b/packages/db/src/lib/__tests__/integration-tool-auto-owner.test.ts @@ -0,0 +1,152 @@ +import { ACP_ENVELOPE_EVENT_TYPES } from '@roomote/types'; + +import { + db, + eq, + fastAgentConversations, + fastAgentMessages, + findSessionPromptSenders, + getIntegrationToolAutoOwner, + resolveSessionIntegrationToolAutoOwner, + userFactory, + users, +} from '../../server'; + +const conversationIds: string[] = []; + +afterEach(async () => { + for (const id of conversationIds.splice(0)) { + await db + .delete(fastAgentConversations) + .where(eq(fastAgentConversations.id, id)); + } +}); + +async function seed() { + const owner = await userFactory.create({ + name: 'Priya Raman', + email: `priya.raman.${Date.now()}@ourco.example`, + }); + const [conversation] = await db + .insert(fastAgentConversations) + .values({ + userId: owner.id, + surface: 'web', + workspaceId: `W-${owner.id}`, + conversationId: `C-${owner.id}`, + }) + .returning({ id: fastAgentConversations.id }); + const conversationId = conversation!.id; + conversationIds.push(conversationId); + let turnSeq = 0; + const prompt = (metadata: Record) => { + turnSeq += 1; + return db.insert(fastAgentMessages).values({ + conversationId, + eventId: `event-${turnSeq}`, + turnId: `turn-${turnSeq}`, + turnSeq, + ts: 1_000 * turnSeq, + eventType: ACP_ENVELOPE_EVENT_TYPES.UserPrompt, + role: 'user', + contentBlocks: [{ type: 'text', text: `message ${turnSeq}` }], + metadata, + payload: {}, + }); + }; + return { owner, conversationId, prompt }; +} + +describe('findSessionPromptSenders', () => { + it('names the sender only when one known person sent every prompt', async () => { + const { owner, conversationId, prompt } = await seed(); + await expect(findSessionPromptSenders(conversationId)).resolves.toEqual({ + kind: 'none', + }); + + await prompt({ turnSource: 'human', userId: owner.id }); + await prompt({ turnSource: 'human', userId: owner.id }); + // Not a person's prompt, whoever it ran as. + await prompt({ turnSource: 'platform_event', userId: 'somebody-else' }); + await expect(findSessionPromptSenders(conversationId)).resolves.toEqual({ + kind: 'one', + userId: owner.id, + }); + await expect( + resolveSessionIntegrationToolAutoOwner({ + conversationId, + ownerUserId: owner.id, + }), + ).resolves.toEqual({ name: 'Priya Raman', email: owner.email }); + // The one sender is not this session's owner. + await expect( + resolveSessionIntegrationToolAutoOwner({ + conversationId, + ownerUserId: 'another-owner', + }), + ).resolves.toBeUndefined(); + + // A reaction from somebody else is still somebody else in the session. + await prompt({ + turnSource: 'human', + inputKind: 'reaction', + userId: 'participant', + }); + await expect(findSessionPromptSenders(conversationId)).resolves.toEqual({ + kind: 'several', + }); + await expect( + resolveSessionIntegrationToolAutoOwner({ + conversationId, + ownerUserId: owner.id, + }), + ).resolves.toBeUndefined(); + }); + + it('counts an unknown sender, or another chat identity under the same account, as several', async () => { + const unknown = await seed(); + await unknown.prompt({ turnSource: 'human' }); + await expect( + findSessionPromptSenders(unknown.conversationId), + ).resolves.toEqual({ kind: 'several' }); + + const chat = await seed(); + await chat.prompt({ + turnSource: 'human', + userId: chat.owner.id, + senderExternalId: 'U-OWNER', + }); + await expect( + findSessionPromptSenders(chat.conversationId), + ).resolves.toEqual({ kind: 'one', userId: chat.owner.id }); + await chat.prompt({ + turnSource: 'human', + userId: chat.owner.id, + senderExternalId: 'U-GUEST', + }); + await expect( + findSessionPromptSenders(chat.conversationId), + ).resolves.toEqual({ kind: 'several' }); + }); +}); + +describe('getIntegrationToolAutoOwner', () => { + it('gives the name and email on the account, and nothing for no account', async () => { + const { owner } = await seed(); + await expect(getIntegrationToolAutoOwner(owner.id)).resolves.toEqual({ + name: 'Priya Raman', + email: owner.email, + }); + await expect( + getIntegrationToolAutoOwner('no-such-user'), + ).resolves.toBeUndefined(); + + await db + .update(users) + .set({ deletedAt: new Date() }) + .where(eq(users.id, owner.id)); + await expect( + getIntegrationToolAutoOwner(owner.id), + ).resolves.toBeUndefined(); + }); +}); diff --git a/packages/db/src/lib/__tests__/integration-tool-auto-task-context.test.ts b/packages/db/src/lib/__tests__/integration-tool-auto-task-context.test.ts index 85c139e75..409da6808 100644 --- a/packages/db/src/lib/__tests__/integration-tool-auto-task-context.test.ts +++ b/packages/db/src/lib/__tests__/integration-tool-auto-task-context.test.ts @@ -278,6 +278,8 @@ describe('resolveTaskIntegrationToolAutoContext', () => { ).resolves.toEqual({ userRequest: 'File the bug.', recentUserMessages: ['File the bug.'], + // The person who launched it wrote the only request. + requestsWrittenBy: owner, recentToolResults: [], }); @@ -292,6 +294,53 @@ describe('resolveTaskIntegrationToolAutoContext', () => { 'File the bug.', 'Also assign it to me.', ]); + expect(later.requestsWrittenBy).toBe(owner); + + // Somebody else then writes to the task: "me" is no longer one person. + await taskMessage({ + ts: 9_500, + text: 'And cc me on it.', + metadata: { userId: 'a-teammate', source: 'slack' }, + }); + await expect( + resolveTaskIntegrationToolAutoContext(context), + ).resolves.not.toHaveProperty('requestsWrittenBy'); + }); + + it('names the one person who wrote every request to the session and the task', async () => { + const { context, owner, sessionMessage, taskMessage } = await seed({ + origin: 'fast_delegation', + prompt: 'Assign ENG-1 to the requester.', + withConversation: true, + }); + const fromOwner = { turnSource: 'human', userId: owner }; + await sessionMessage({ + ts: 1_000, + text: 'Assign ENG-1 to me.', + metadata: fromOwner, + }); + // The agent wrote the launch prompt, so it has no author to count. + await expect( + resolveTaskIntegrationToolAutoContext(context), + ).resolves.toMatchObject({ + userRequest: 'Assign ENG-1 to me.', + requestsWrittenBy: owner, + }); + + await taskMessage({ + ts: 4_000, + text: 'ENG-2 as well.', + metadata: { userId: owner, source: 'web' }, + }); + await expect( + resolveTaskIntegrationToolAutoContext(context), + ).resolves.toMatchObject({ requestsWrittenBy: owner }); + + // A session prompt whose sender is not recorded could be anybody's. + await sessionMessage({ ts: 5_000, text: 'And ENG-3 to me.' }); + await expect( + resolveTaskIntegrationToolAutoContext(context), + ).resolves.not.toHaveProperty('requestsWrittenBy'); }); it('has nothing to judge against for a task outside the session', async () => { diff --git a/packages/db/src/lib/integration-tool-auto-owner.ts b/packages/db/src/lib/integration-tool-auto-owner.ts new file mode 100644 index 000000000..fd38f5dd0 --- /dev/null +++ b/packages/db/src/lib/integration-tool-auto-owner.ts @@ -0,0 +1,79 @@ +import { and, eq, isNull, sql } from 'drizzle-orm'; + +import { ACP_ENVELOPE_EVENT_TYPES } from '@roomote/types'; + +import { db } from '../db'; +import { fastAgentMessages, users } from '../schema'; + +/** + * Who sent the human prompts of a session's conversation: nobody yet, one + * person, or several. A prompt without a recorded sender counts as several, + * so the answer is "one" only when every prompt is known to be that person's. + * A chat sender with no account of their own acts under another user's id, so + * the chat identity has to be the same throughout as well. + */ +export type SessionPromptSenders = + | { kind: 'none' } + | { kind: 'one'; userId: string } + | { kind: 'several' }; + +export async function findSessionPromptSenders( + conversationId: string, +): Promise { + const rows = await db + .selectDistinct({ + userId: sql`${fastAgentMessages.metadata}->>'userId'`, + externalId: sql`coalesce(${fastAgentMessages.metadata}->>'senderExternalId', '')`, + }) + .from(fastAgentMessages) + .where( + and( + eq(fastAgentMessages.conversationId, conversationId), + eq(fastAgentMessages.eventType, ACP_ENVELOPE_EVENT_TYPES.UserPrompt), + eq(fastAgentMessages.role, 'user'), + sql`${fastAgentMessages.metadata}->>'turnSource' = 'human'`, + ), + ) + .limit(2); + const [only, another] = rows; + if (!only) return { kind: 'none' }; + return another || !only.userId + ? { kind: 'several' } + : { kind: 'one', userId: only.userId }; +} + +/** + * The session owner as Auto is told about them: the name and email on their + * account. Auto uses it to tell a call that names the owner from one that + * names somebody else. + */ +export async function getIntegrationToolAutoOwner( + userId: string, +): Promise<{ name?: string; email?: string } | undefined> { + const [user] = await db + .select({ name: users.name, email: users.email }) + .from(users) + .where(and(eq(users.id, userId), isNull(users.deletedAt))) + .limit(1); + if (!user) return undefined; + const name = user.name.trim(); + const email = user.email.trim(); + if (!name && !email) return undefined; + return { ...(name ? { name } : {}), ...(email ? { email } : {}) }; +} + +/** + * The owner of a session whose every human prompt they sent themselves, so + * "me" in those prompts is the owner. Undefined when anybody else wrote one, + * or a sender is unknown: then nothing says who "me" is. + */ +export async function resolveSessionIntegrationToolAutoOwner(input: { + conversationId: string; + ownerUserId: string; +}): Promise<{ name?: string; email?: string } | undefined> { + const senders = await findSessionPromptSenders(input.conversationId); + if (senders.kind !== 'one' || senders.userId !== input.ownerUserId) { + return undefined; + } + return getIntegrationToolAutoOwner(input.ownerUserId); +} diff --git a/packages/db/src/lib/integration-tool-auto-task-context.ts b/packages/db/src/lib/integration-tool-auto-task-context.ts index 8597eb847..05e47f0bb 100644 --- a/packages/db/src/lib/integration-tool-auto-task-context.ts +++ b/packages/db/src/lib/integration-tool-auto-task-context.ts @@ -11,6 +11,7 @@ import { } from '@roomote/types'; import { db } from '../db'; +import { findSessionPromptSenders } from './integration-tool-auto-owner'; import { fastAgentMessages, sessions, @@ -49,6 +50,11 @@ export type TaskIntegrationToolAutoContext = { }>; /** What the task's agent read since a person last sent it a prompt. */ readContent?: string; + /** + * The one person who wrote every request in `recentUserMessages`, when + * that is known: "me" in those requests is then this person. + */ + requestsWrittenBy?: string; }; type Request = { @@ -56,6 +62,8 @@ type Request = { text: string; from: 'session' | 'task' | 'launch'; eventId?: string; + /** Who sent it, where the row says. Session prompts are looked up apart. */ + userId?: string; }; function textOf( @@ -115,6 +123,7 @@ async function listTaskHumanPrompts(taskId: string): Promise { ts: taskMessages.ts, contentBlocks: taskMessages.contentBlocks, payload: taskMessages.payload, + userId: sql`${taskMessages.metadata}->>'userId'`, }) .from(taskMessages) .where( @@ -131,7 +140,16 @@ async function listTaskHumanPrompts(taskId: string): Promise { const text = toIntegrationToolUserRequest( textOf(row.contentBlocks, row.payload), ); - return text ? [{ ts: row.ts, text, from: 'task' as const }] : []; + return text + ? [ + { + ts: row.ts, + text, + from: 'task' as const, + ...(row.userId ? { userId: row.userId } : {}), + }, + ] + : []; }); } @@ -339,6 +357,7 @@ export async function resolveTaskIntegrationToolAutoContext(input: { fastConversationId: sessions.fastConversationId, prompt: tasks.prompt, createdAt: tasks.createdAt, + initiatorUserId: tasks.initiatorUserId, }) .from(sessionTasks) .innerJoin(sessions, eq(sessions.id, sessionTasks.sessionId)) @@ -374,6 +393,7 @@ export async function resolveTaskIntegrationToolAutoContext(input: { ts: link.createdAt.getTime(), text: launchPrompt, from: 'launch' as const, + ...(link.initiatorUserId ? { userId: link.initiatorUserId } : {}), }, ] : []), @@ -400,9 +420,28 @@ export async function resolveTaskIntegrationToolAutoContext(input: { }); } + // One sender for the whole session conversation, checked over every + // prompt in it; the task's own prompts and its launch each name theirs. + const sessionSenders = + conversationId && sessionPrompts.length > 0 + ? await findSessionPromptSenders(conversationId) + : undefined; + const authors = [ + ...(sessionSenders + ? [sessionSenders.kind === 'one' ? sessionSenders.userId : undefined] + : []), + ...requests + .filter((request) => request.from !== 'session') + .map((request) => request.userId), + ]; + const [author] = authors; + const requestsWrittenBy = + author && authors.every((other) => other === author) ? author : undefined; + return { ...(latest ? { userRequest: latest.text } : {}), recentUserMessages: requests.map((request) => request.text), + ...(requestsWrittenBy ? { requestsWrittenBy } : {}), ...(agentMessageRepliedTo ? { agentMessageRepliedTo } : {}), recentToolResults: [ ...(delegated && launchPrompt diff --git a/packages/db/src/server.ts b/packages/db/src/server.ts index 9f3515a5f..c68e6dbea 100644 --- a/packages/db/src/server.ts +++ b/packages/db/src/server.ts @@ -56,6 +56,7 @@ export * from './lib/tasks'; export * from './lib/sessions'; export * from './lib/service-credentials'; export * from './lib/integration-tool-approvals'; +export * from './lib/integration-tool-auto-owner'; export * from './lib/integration-tool-auto-task-context'; export * from './lib/integration-tool-auto-settings'; export * from './lib/credential-egress'; diff --git a/packages/sdk/src/server/lib/__tests__/task-tool-approvals.test.ts b/packages/sdk/src/server/lib/__tests__/task-tool-approvals.test.ts index b3d87d78f..62c9ecdcb 100644 --- a/packages/sdk/src/server/lib/__tests__/task-tool-approvals.test.ts +++ b/packages/sdk/src/server/lib/__tests__/task-tool-approvals.test.ts @@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({ suspended: vi.fn(async () => false), suspend: vi.fn(async () => true), latestUserRequest: vi.fn(async () => undefined as string | undefined), + autoOwner: vi.fn(async () => undefined as unknown), taskContext: vi.fn( async () => ({ recentUserMessages: [], recentToolResults: [] }) as unknown, ), @@ -80,6 +81,7 @@ vi.mock('@roomote/db/server', () => ({ expireIntegrationToolApproval: mocks.expire, fingerprintIntegrationToolCall: (input: unknown) => JSON.stringify(input), findLatestTaskUserRequest: mocks.latestUserRequest, + getIntegrationToolAutoOwner: mocks.autoOwner, resolveTaskIntegrationToolAutoContext: mocks.taskContext, listRecentIntegrationToolApprovalOutcomes: mocks.outcomes, hasRejectedIntegrationToolInSession: mocks.toolRejected, @@ -133,6 +135,7 @@ beforeEach(() => { mocks.isPresent.mockResolvedValue(true); mocks.suspended.mockResolvedValue(false); mocks.latestUserRequest.mockResolvedValue(undefined); + mocks.autoOwner.mockResolvedValue(undefined); mocks.taskContext.mockResolvedValue({ recentUserMessages: [], recentToolResults: [], @@ -309,6 +312,52 @@ describe('requestTaskToolApproval', () => { expect(mocks.delegated).toHaveBeenCalledWith('session-1', 'task-2'); }); + it('names the owner to Auto only when they wrote every request shown', async () => { + mocks.resolveAuto.mockResolvedValue({ action: 'run', mode: 'off' }); + const owner = { name: 'Priya Raman', email: 'priya.raman@ourco.example' }; + mocks.autoOwner.mockResolvedValue(owner); + const sessionContexts = () => + ( + mocks.resolveAuto.mock.calls as unknown as [ + { sessionContext: Record }, + ][] + ).map(([call]) => call.sessionContext); + const context = { + userRequest: 'Assign ENG-1 to me.', + recentUserMessages: ['Assign ENG-1 to me.'], + recentToolResults: [], + }; + + mocks.taskContext.mockResolvedValue({ + ...context, + requestsWrittenBy: 'owner-1', + }); + await requestTaskToolApproval(ask); + expect(mocks.autoOwner).toHaveBeenCalledWith('owner-1'); + expect(sessionContexts()[0]).toMatchObject({ owner }); + + // Somebody else wrote a request, or nobody knows who did: "me" in the + // requests is not known to be the owner, so the owner is not named. + for (const requestsWrittenBy of ['a-teammate', undefined]) { + mocks.autoOwner.mockClear(); + mocks.resolveAuto.mockClear(); + mocks.taskContext.mockResolvedValue({ ...context, requestsWrittenBy }); + await requestTaskToolApproval(ask); + expect(mocks.autoOwner).not.toHaveBeenCalled(); + expect(sessionContexts()[0]).not.toHaveProperty('owner'); + } + + // A failed lookup leaves the owner unnamed; the call is still assessed. + mocks.resolveAuto.mockClear(); + mocks.taskContext.mockResolvedValue({ + ...context, + requestsWrittenBy: 'owner-1', + }); + mocks.autoOwner.mockRejectedValue(new Error('db down')); + await requestTaskToolApproval(ask); + expect(sessionContexts()[0]).not.toHaveProperty('owner'); + }); + it("shows Auto what the server says the tool does, listing a run's server once", async () => { mocks.resolveAuto.mockResolvedValue({ action: 'run', mode: 'off' }); const assessed = () => diff --git a/packages/sdk/src/server/lib/task-tool-approvals.ts b/packages/sdk/src/server/lib/task-tool-approvals.ts index f6ee83f11..c209af165 100644 --- a/packages/sdk/src/server/lib/task-tool-approvals.ts +++ b/packages/sdk/src/server/lib/task-tool-approvals.ts @@ -5,6 +5,7 @@ import { expireIntegrationToolApproval, fingerprintIntegrationToolCall, getIntegrationToolApproval, + getIntegrationToolAutoOwner, findLatestTaskUserRequest, getSessionForTask, hasRejectedIntegrationToolInSession, @@ -402,9 +403,18 @@ async function resolveTaskAutoContext(input: { context?.userRequest ?? toIntegrationToolUserRequest(input.reportedUserRequest) ?? (await findLatestTaskUserRequest(input.taskId).catch(() => undefined)); + // Named only when the owner wrote every request shown, so "me" in them is + // the owner. A failed lookup leaves the owner unnamed, as before. + const owner = + context?.requestsWrittenBy === input.ownerUserId + ? await getIntegrationToolAutoOwner(input.ownerUserId).catch( + () => undefined, + ) + : undefined; return { userRequest, sessionContext: { + ...(owner ? { owner } : {}), recentUserMessages: context?.recentUserMessages ?? [], explicitApprovalOutcomes, ...(context?.agentMessageRepliedTo From 951acdf265a63d766882c8cd354012e273f0db73 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Fri, 2 Oct 2026 16:49:06 -0500 Subject: [PATCH 2/2] [Improve] Count every task prompt when deciding who wrote the requests Only the recent task prompts were checked, so an earlier prompt from somebody else stopped counting once it left the history and the owner could be named in a shared task. Senders are now checked over all of the task's prompts, as they already were for the session. --- ...integration-tool-auto-task-context.test.ts | 24 +++++++++ .../db/src/lib/integration-tool-auto-owner.ts | 51 +++++++++++++++---- .../lib/integration-tool-auto-task-context.ts | 40 +++++++-------- 3 files changed, 83 insertions(+), 32 deletions(-) diff --git a/packages/db/src/lib/__tests__/integration-tool-auto-task-context.test.ts b/packages/db/src/lib/__tests__/integration-tool-auto-task-context.test.ts index 409da6808..506c98d43 100644 --- a/packages/db/src/lib/__tests__/integration-tool-auto-task-context.test.ts +++ b/packages/db/src/lib/__tests__/integration-tool-auto-task-context.test.ts @@ -343,6 +343,30 @@ describe('resolveTaskIntegrationToolAutoContext', () => { ).resolves.not.toHaveProperty('requestsWrittenBy'); }); + it('still counts somebody else’s prompt after it has left the recent history', async () => { + const { context, owner, taskMessage } = await seed({ + origin: 'direct_launch', + prompt: 'File the bug.', + withConversation: false, + }); + await taskMessage({ + ts: 9_000, + text: 'Assign it to me.', + metadata: { userId: 'a-teammate', source: 'slack' }, + }); + // More prompts from the owner than the history keeps. + for (let index = 0; index < 25; index += 1) { + await taskMessage({ + ts: 10_000 + index, + text: `Owner follow-up ${index}.`, + metadata: { userId: owner, source: 'web' }, + }); + } + const resolved = await resolveTaskIntegrationToolAutoContext(context); + expect(resolved.recentUserMessages).not.toContain('Assign it to me.'); + expect(resolved).not.toHaveProperty('requestsWrittenBy'); + }); + it('has nothing to judge against for a task outside the session', async () => { const { context } = await seed({ origin: 'direct_launch', diff --git a/packages/db/src/lib/integration-tool-auto-owner.ts b/packages/db/src/lib/integration-tool-auto-owner.ts index fd38f5dd0..911575ecf 100644 --- a/packages/db/src/lib/integration-tool-auto-owner.ts +++ b/packages/db/src/lib/integration-tool-auto-owner.ts @@ -3,23 +3,25 @@ import { and, eq, isNull, sql } from 'drizzle-orm'; import { ACP_ENVELOPE_EVENT_TYPES } from '@roomote/types'; import { db } from '../db'; -import { fastAgentMessages, users } from '../schema'; +import { fastAgentMessages, taskMessages, users } from '../schema'; -/** - * Who sent the human prompts of a session's conversation: nobody yet, one - * person, or several. A prompt without a recorded sender counts as several, - * so the answer is "one" only when every prompt is known to be that person's. - * A chat sender with no account of their own acts under another user's id, so - * the chat identity has to be the same throughout as well. - */ -export type SessionPromptSenders = +/** Who sent a set of prompts: nobody yet, one person, or several. */ +type PromptSenders = | { kind: 'none' } | { kind: 'one'; userId: string } | { kind: 'several' }; +/** + * Who sent the human prompts of a session's conversation, over all of them + * rather than the recent ones a caller shows. A prompt without a recorded + * sender counts as several, so the answer is "one" only when every prompt is + * known to be that person's. A chat sender with no account of their own acts + * under another user's id, so the chat identity has to be the same throughout + * as well. + */ export async function findSessionPromptSenders( conversationId: string, -): Promise { +): Promise { const rows = await db .selectDistinct({ userId: sql`${fastAgentMessages.metadata}->>'userId'`, @@ -42,6 +44,35 @@ export async function findSessionPromptSenders( : { kind: 'one', userId: only.userId }; } +/** + * Who sent the prompts people sent to a task itself, over all of them: an + * earlier prompt from somebody else still makes the task a shared one after + * it has left the recent history. Prompts the platform or the harness sends + * carry no sender and are not counted. These rows are recorded by the task's + * own worker. + */ +export async function findTaskPromptSenders( + taskId: string, +): Promise { + const rows = await db + .selectDistinct({ + userId: sql`${taskMessages.metadata}->>'userId'`, + externalId: sql`coalesce(${taskMessages.metadata}->>'senderExternalId', '')`, + }) + .from(taskMessages) + .where( + and( + eq(taskMessages.taskId, taskId), + eq(taskMessages.eventType, ACP_ENVELOPE_EVENT_TYPES.UserPrompt), + sql`${taskMessages.metadata}->>'userId' is not null`, + ), + ) + .limit(2); + const [only, another] = rows; + if (!only) return { kind: 'none' }; + return another ? { kind: 'several' } : { kind: 'one', userId: only.userId }; +} + /** * The session owner as Auto is told about them: the name and email on their * account. Auto uses it to tell a call that names the owner from one that diff --git a/packages/db/src/lib/integration-tool-auto-task-context.ts b/packages/db/src/lib/integration-tool-auto-task-context.ts index 05e47f0bb..725730765 100644 --- a/packages/db/src/lib/integration-tool-auto-task-context.ts +++ b/packages/db/src/lib/integration-tool-auto-task-context.ts @@ -11,7 +11,10 @@ import { } from '@roomote/types'; import { db } from '../db'; -import { findSessionPromptSenders } from './integration-tool-auto-owner'; +import { + findSessionPromptSenders, + findTaskPromptSenders, +} from './integration-tool-auto-owner'; import { fastAgentMessages, sessions, @@ -62,7 +65,7 @@ type Request = { text: string; from: 'session' | 'task' | 'launch'; eventId?: string; - /** Who sent it, where the row says. Session prompts are looked up apart. */ + /** Who launched the task, for its launch prompt. */ userId?: string; }; @@ -123,7 +126,6 @@ async function listTaskHumanPrompts(taskId: string): Promise { ts: taskMessages.ts, contentBlocks: taskMessages.contentBlocks, payload: taskMessages.payload, - userId: sql`${taskMessages.metadata}->>'userId'`, }) .from(taskMessages) .where( @@ -140,16 +142,7 @@ async function listTaskHumanPrompts(taskId: string): Promise { const text = toIntegrationToolUserRequest( textOf(row.contentBlocks, row.payload), ); - return text - ? [ - { - ts: row.ts, - text, - from: 'task' as const, - ...(row.userId ? { userId: row.userId } : {}), - }, - ] - : []; + return text ? [{ ts: row.ts, text, from: 'task' as const }] : []; }); } @@ -420,18 +413,21 @@ export async function resolveTaskIntegrationToolAutoContext(input: { }); } - // One sender for the whole session conversation, checked over every - // prompt in it; the task's own prompts and its launch each name theirs. - const sessionSenders = + // Senders are checked over every prompt of the session and of the task, + // not only the recent ones shown: somebody else's earlier prompt still + // makes "me" ambiguous after it has left the history. + const [sessionSenders, taskSenders] = await Promise.all([ conversationId && sessionPrompts.length > 0 - ? await findSessionPromptSenders(conversationId) - : undefined; + ? findSessionPromptSenders(conversationId) + : undefined, + taskPrompts.length > 0 ? findTaskPromptSenders(input.taskId) : undefined, + ]); const authors = [ - ...(sessionSenders - ? [sessionSenders.kind === 'one' ? sessionSenders.userId : undefined] - : []), + ...[sessionSenders, taskSenders].flatMap((senders) => + senders ? [senders.kind === 'one' ? senders.userId : undefined] : [], + ), ...requests - .filter((request) => request.from !== 'session') + .filter((request) => request.from === 'launch') .map((request) => request.userId), ]; const [author] = authors;