Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
46 changes: 22 additions & 24 deletions apps/api/src/handlers/mcp/unasked-task-tool-call.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { fingerprintIntegrationToolCall } from '@roomote/db/server';
import { waitForIntegrationToolApproval } from '@roomote/sdk/tool-approval-wait';
import {
INTEGRATION_TOOL_AUTO_PAUSED_AGENT_MESSAGE,
describeIntegrationToolAutoAbsentDenial,
Expand All @@ -9,7 +10,6 @@ import {
describeProxyToolApprovalBlock,
} from './tool-approval-enforcement';

const APPROVAL_POLL_MS = 1_500;
/**
* Longer than an approval stays open, so a wait ends on the owner's decision
* or on the approval expiring, never on this limit in normal operation.
Expand Down Expand Up @@ -127,30 +127,28 @@ export async function decideUnaskedTaskToolCall(input: {
break;
}

const pollMs = input.pollMs ?? APPROVAL_POLL_MS;
const deadline = Date.now() + APPROVAL_MAX_WAIT_MS;
while (Date.now() < deadline) {
if (callerLeft()) return refused;
const status = await getTaskToolApprovalStatus({
runId,
approvalId: result.approvalId,
});
const decision = await waitForIntegrationToolApproval({
readStatus: () =>
getTaskToolApprovalStatus({ runId, approvalId: result.approvalId }),
// The owner's approval is consumed here.
if (status === 'approved') return claim();
if (status === 'expired') {
return {
allowed: false,
message:
'The requester did not answer in time; the tool call was not run.',
};
}
if (status !== 'pending') {
return {
allowed: false,
message: 'The requester rejected this tool call.',
};
}
await new Promise((resolve) => setTimeout(resolve, pollMs));
claimApproved: async () => (await claim()).allowed,
signal: input.signal,
pollMs: input.pollMs,
deadline: Date.now() + APPROVAL_MAX_WAIT_MS,
});
if (decision === 'approved') return { allowed: true };
if (decision === 'expired') {
return {
allowed: false,
message:
'The requester did not answer in time; the tool call was not run.',
};
}
if (decision === 'rejected') {
return {
allowed: false,
message: 'The requester rejected this tool call.',
};
}
return refused;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

import { sdk } from '@roomote/sdk/client';
import { waitForIntegrationToolApproval } from '@roomote/sdk/tool-approval-wait';
import {
INTEGRATION_TOOL_AUTO_PAUSED_AGENT_MESSAGE,
describeIntegrationToolAutoAbsentDenial,
Expand All @@ -14,7 +15,6 @@ import { parseDirectMcpConfig } from './mcp-config';
import type { OpenCodeServerClient } from './client';
import type { OpenCodeToolPart } from './types';

const TOOL_APPROVAL_POLL_MS = 1_500;
const AUTO_SERVER_TOOL_LIST_TIMEOUT_MS = 15_000;

type TaskToolApprovalApi = Pick<typeof sdk.toolApprovals, 'request' | 'status'>;
Expand Down Expand Up @@ -171,7 +171,6 @@ export function createTaskToolApprovalRelay(options: {
onPendingCountChange?: (pending: number) => void;
}) {
const api = options.api ?? sdk.toolApprovals;
const pollMs = options.pollMs ?? TOOL_APPROVAL_POLL_MS;
const handled = new Set<string>();
let pending = 0;

Expand Down Expand Up @@ -245,27 +244,25 @@ export function createTaskToolApprovalRelay(options: {
);
return;
}
for (;;) {
if (options.signal.aborted) return;
const { status } = await api.status(result.approvalId);
if (status === 'approved') {
await reply(ask, 'once');
return;
}
if (status === 'expired') {
await reply(
ask,
'reject',
'The requester did not answer in time; the tool call was not run.',
);
return;
}
if (status !== 'pending') {
await reply(ask, 'reject', 'The requester rejected this tool call.');
return;
}
await new Promise((resolve) => setTimeout(resolve, pollMs));
const decision = await waitForIntegrationToolApproval({
readStatus: async () => (await api.status(result.approvalId)).status,
signal: options.signal,
pollMs: options.pollMs,
});
if (decision === 'aborted') return;
if (decision === 'approved') {
await reply(ask, 'once');
return;
}
if (decision === 'expired') {
await reply(
ask,
'reject',
'The requester did not answer in time; the tool call was not run.',
);
return;
}
await reply(ask, 'reject', 'The requester rejected this tool call.');
};

/** Fire-and-forget from the event loop; the pause is the intended state. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ import {
suspendIntegrationToolAutoForSession,
} from '@roomote/db/server';
import { isSessionUserPresent } from '@roomote/redis';
import {
waitForIntegrationToolApproval,
type IntegrationToolApprovalWaitResult,
} from '@roomote/sdk/tool-approval-wait';
import {
INTEGRATION_TOOL_AUTO_PAUSED_AGENT_MESSAGE,
describeIntegrationToolAutoAbsentDenial,
Expand Down Expand Up @@ -70,9 +74,7 @@ import { buildFastAgentCodeModeServerNames } from './fast-agent-tool-policy';
* per ask, which binds the approval to the exact paused call; a repeated
* call with changed arguments is a new ask by construction.
*/
const INTEGRATION_TOOL_APPROVAL_POLL_MS = 1_500;

type CardDecision = 'approved' | 'rejected' | 'expired' | 'invalid' | 'aborted';
type CardDecision = IntegrationToolApprovalWaitResult;
const SESSION_PRESENCE_LOOKUP_TIMEOUT_MS = 2_000;
/**
* An open session page renews its presence every 10 seconds, and a page that
Expand Down Expand Up @@ -534,32 +536,21 @@ export function createFastAgentToolApprovalBridge(input: {
notifiedApprovalIds.add(approval.approvalId);
await input.notify(approval);
}
const deadline = Date.parse(approval.expiresAt);
for (;;) {
if (input.signal?.aborted) return 'aborted';
const row = await getIntegrationToolApproval(approval.approvalId);
if (!row || row.status === 'rejected' || row.status === 'cancelled') {
return 'rejected';
}
if (row.status === 'expired') return 'expired';
if (row.status === 'approved') {
// Consume before relaying: only the first relay of an approved,
// unclaimed decision reaches OpenCode; a cancelled or
// double-claimed row fails closed instead of executing twice.
const consumed = await markIntegrationToolApprovalConsumed({
return waitForIntegrationToolApproval({
readStatus: async () =>
(await getIntegrationToolApproval(approval.approvalId))?.status ?? null,
// Consume before relaying: only the first relay of an approved,
// unclaimed decision reaches OpenCode; a cancelled or double-claimed
// row fails closed instead of executing twice.
claimApproved: () =>
markIntegrationToolApprovalConsumed({
approvalId: approval.approvalId,
requesterUserId: input.userId,
});
return consumed ? 'approved' : 'invalid';
}
if (Date.now() >= deadline) {
await expireIntegrationToolApproval(approval.approvalId);
return 'expired';
}
await new Promise((resolve) =>
setTimeout(resolve, INTEGRATION_TOOL_APPROVAL_POLL_MS),
);
}
}),
signal: input.signal,
deadline: Date.parse(approval.expiresAt),
expire: () => expireIntegrationToolApproval(approval.approvalId),
});
};
// Once Auto stops in this turn the turn is ending: no other call runs or
// leaves a card waiting.
Expand Down
4 changes: 4 additions & 0 deletions packages/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
"import": "./src/sandbox-router.ts",
"require": "./src/sandbox-router.ts"
},
"./tool-approval-wait": {
"import": "./src/tool-approval-wait.ts",
"require": "./src/tool-approval-wait.ts"
},
"./server": {
"import": "./src/server/index.ts",
"require": "./src/server/index.ts"
Expand Down
128 changes: 128 additions & 0 deletions packages/sdk/src/tool-approval-wait.test.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading