From 46b76ccf0dff2622aba85196a583c4028671b841 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Fri, 7 Aug 2026 18:04:13 -0700 Subject: [PATCH 1/3] feat(tasks): accept side_question commands on task runs Adds side_question to the task-run command channel: the serializer validates a non-empty question, and the view forwards the command to the sandbox agent server like other sandbox control commands. side_question requires Code access (has_tasks_access): it drives the agent and spends model tokens on the caller's behalf. user_message stays ungated so the Inbox Discuss flow keeps working. Nothing calls this yet. The desktop /btw command that uses it ships separately, so a client on the old build is unaffected and a sandbox predating the command rejects it with an error the caller sees. Generated-By: PostHog Code Task-Id: 9086ddae-4430-49ef-b956-66f90f356b00 --- .../tasks/backend/presentation/serializers.py | 4 ++ .../tasks/backend/presentation/views/api.py | 12 ++++- products/tasks/backend/tests/test_api.py | 49 +++++++++++++++++++ .../tasks/frontend/generated/api.schemas.ts | 5 +- products/tasks/frontend/generated/api.ts | 2 +- products/tasks/frontend/generated/api.zod.ts | 7 +-- services/mcp/src/api/generated.ts | 5 +- 7 files changed, 77 insertions(+), 7 deletions(-) diff --git a/products/tasks/backend/presentation/serializers.py b/products/tasks/backend/presentation/serializers.py index 4bb59c291151..68365addfb50 100644 --- a/products/tasks/backend/presentation/serializers.py +++ b/products/tasks/backend/presentation/serializers.py @@ -2991,6 +2991,7 @@ class TaskRunCommandRequestSerializer(serializers.Serializer): "pi/rpc", "queue_get", "queue_clear", + "side_question", ] # Cap on the serialized mcp_response params (docs/CLOUD-MCP-RELAY.md): the relayed JSON-RPC @@ -3063,6 +3064,9 @@ def validate(self, attrs): raise serializers.ValidationError( {"params": "user_message requires a non-empty content string, artifact_ids, or both"} ) + elif method == "side_question": + self._require_nonempty_string(params, "question") + params["question"] = params["question"].strip() elif method == "pi/rpc": command = params.get("command") if not isinstance(command, dict): diff --git a/products/tasks/backend/presentation/views/api.py b/products/tasks/backend/presentation/views/api.py index 41cdf45f0900..3942cc4dedf2 100644 --- a/products/tasks/backend/presentation/views/api.py +++ b/products/tasks/backend/presentation/views/api.py @@ -2125,7 +2125,7 @@ def stream_token(self, request, pk=None, **kwargs): summary="Send command to task run", description="Queue user_message JSON-RPC commands through the task workflow and forward sandbox control " "commands to the agent server. Supports user_message, cancel, close, permission_response, " - "set_config_option, mcp_response, native Pi RPC commands, and Pi queue operations.", + "set_config_option, mcp_response, side_question, native Pi RPC commands, and Pi queue operations.", strict_request_validation=True, ) @action( @@ -2159,6 +2159,16 @@ def command(self, request, pk=None, **kwargs): request_id = request.validated_data.get("id") params = request.validated_data.get("params") + # Unlike user_message (deliberately ungated below so Inbox "Discuss" works), + # side_question is only surfaced by the Desktop client, so it can require Code access. + if method == "side_question" and not tasks_access.has_tasks_access(request.user): + return Response( + TaskRunErrorResponseSerializer( + {"error": "side_question requires PostHog Code access.", "code": "code_access_required"} + ).data, + status=status.HTTP_403_FORBIDDEN, + ) + if method == "user_message": # The Inbox starts interactive runs and drops the user straight into this composer, # so "Discuss" and scout chat replies must not require Desktop access. Their tasks diff --git a/products/tasks/backend/tests/test_api.py b/products/tasks/backend/tests/test_api.py index f69846fb53f3..3bab10615858 100644 --- a/products/tasks/backend/tests/test_api.py +++ b/products/tasks/backend/tests/test_api.py @@ -9820,6 +9820,55 @@ def test_command_proxies_cancel(self, mock_post): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertTrue(response.json()["result"]["cancelled"]) + @override_settings(SANDBOX_JWT_PRIVATE_KEY=TEST_RSA_PRIVATE_KEY) + @patch("products.tasks.backend.presentation.views.api.http_requests.post") + def test_command_proxies_side_question(self, mock_post): + reset_sandbox_jwt_key_cache() + self._mock_agent_response( + mock_post, + {"jsonrpc": "2.0", "id": "req-btw", "result": {"answer": "It parses JSONL."}}, + ) + + task = self.create_task() + run = self._create_run_with_sandbox(task) + + response = self.client.post( + self._command_url(task, run), + {"jsonrpc": "2.0", "method": "side_question", "params": {"question": "what does it do?"}, "id": "req-btw"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json()["result"]["answer"], "It parses JSONL.") + + @parameterized.expand([("blank", " "), ("empty", ""), ("missing", None)]) + def test_command_rejects_side_question_without_a_question(self, _name, question): + task = self.create_task() + run = self._create_run_with_sandbox(task) + params = {} if question is None else {"question": question} + + response = self.client.post( + self._command_url(task, run), + {"jsonrpc": "2.0", "method": "side_question", "params": params, "id": "req-btw"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_command_side_question_requires_code_access(self): + self.set_tasks_feature_flag(False) + task = self.create_task() + run = self._create_run_with_sandbox(task) + + response = self.client.post( + self._command_url(task, run), + {"jsonrpc": "2.0", "method": "side_question", "params": {"question": "why?"}, "id": "req-btw"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.json()["code"], "code_access_required") + def test_empty_task_session_returns_read_only_storage_access(self): task = self.create_task(runtime=Task.Runtime.PI) run = self._create_run_with_sandbox(task) diff --git a/products/tasks/frontend/generated/api.schemas.ts b/products/tasks/frontend/generated/api.schemas.ts index 0da815226e2a..5b97a920a539 100644 --- a/products/tasks/frontend/generated/api.schemas.ts +++ b/products/tasks/frontend/generated/api.schemas.ts @@ -3226,6 +3226,7 @@ export const JsonrpcEnumApi = { * * `pi/rpc` - pi/rpc * * `queue_get` - queue_get * * `queue_clear` - queue_clear + * * `side_question` - side_question */ export type TaskRunCommandRequestMethodEnumApi = (typeof TaskRunCommandRequestMethodEnumApi)[keyof typeof TaskRunCommandRequestMethodEnumApi] @@ -3240,6 +3241,7 @@ export const TaskRunCommandRequestMethodEnumApi = { PiRpc: 'pi/rpc', QueueGet: 'queue_get', QueueClear: 'queue_clear', + SideQuestion: 'side_question', } as const /** @@ -3260,7 +3262,8 @@ export interface TaskRunCommandRequestApi { * * `mcp_response` - mcp_response * * `pi/rpc` - pi/rpc * * `queue_get` - queue_get - * * `queue_clear` - queue_clear */ + * * `queue_clear` - queue_clear + * * `side_question` - side_question */ method: TaskRunCommandRequestMethodEnumApi /** Parameters for the command */ params?: TaskRunCommandRequestApiParams diff --git a/products/tasks/frontend/generated/api.ts b/products/tasks/frontend/generated/api.ts index 83b38252e578..5cb1ba472f54 100644 --- a/products/tasks/frontend/generated/api.ts +++ b/products/tasks/frontend/generated/api.ts @@ -1906,7 +1906,7 @@ export const getTasksRunsCommandCreateUrl = (projectId: string, taskId: string, } /** - * Queue user_message JSON-RPC commands through the task workflow and forward sandbox control commands to the agent server. Supports user_message, cancel, close, permission_response, set_config_option, mcp_response, native Pi RPC commands, and Pi queue operations. + * Queue user_message JSON-RPC commands through the task workflow and forward sandbox control commands to the agent server. Supports user_message, cancel, close, permission_response, set_config_option, mcp_response, side_question, native Pi RPC commands, and Pi queue operations. * @summary Send command to task run */ export const tasksRunsCommandCreate = async ( diff --git a/products/tasks/frontend/generated/api.zod.ts b/products/tasks/frontend/generated/api.zod.ts index 06eeb2db49d8..10bd53efb5f1 100644 --- a/products/tasks/frontend/generated/api.zod.ts +++ b/products/tasks/frontend/generated/api.zod.ts @@ -2766,7 +2766,7 @@ export const TasksRunsCancelCreateBody = /* @__PURE__ */ zod.object({ }) /** - * Queue user_message JSON-RPC commands through the task workflow and forward sandbox control commands to the agent server. Supports user_message, cancel, close, permission_response, set_config_option, mcp_response, native Pi RPC commands, and Pi queue operations. + * Queue user_message JSON-RPC commands through the task workflow and forward sandbox control commands to the agent server. Supports user_message, cancel, close, permission_response, set_config_option, mcp_response, side_question, native Pi RPC commands, and Pi queue operations. * @summary Send command to task run */ export const TasksRunsCommandCreateBody = /* @__PURE__ */ zod @@ -2786,12 +2786,13 @@ export const TasksRunsCommandCreateBody = /* @__PURE__ */ zod 'pi/rpc', 'queue_get', 'queue_clear', + 'side_question', ]) .describe( - '\* `user_message` - user_message\n\* `cancel` - cancel\n\* `close` - close\n\* `permission_response` - permission_response\n\* `set_config_option` - set_config_option\n\* `mcp_response` - mcp_response\n\* `pi\/rpc` - pi\/rpc\n\* `queue_get` - queue_get\n\* `queue_clear` - queue_clear' + '\* `user_message` - user_message\n\* `cancel` - cancel\n\* `close` - close\n\* `permission_response` - permission_response\n\* `set_config_option` - set_config_option\n\* `mcp_response` - mcp_response\n\* `pi\/rpc` - pi\/rpc\n\* `queue_get` - queue_get\n\* `queue_clear` - queue_clear\n\* `side_question` - side_question' ) .describe( - 'Command method to execute on the agent server\n\n\* `user_message` - user_message\n\* `cancel` - cancel\n\* `close` - close\n\* `permission_response` - permission_response\n\* `set_config_option` - set_config_option\n\* `mcp_response` - mcp_response\n\* `pi\/rpc` - pi\/rpc\n\* `queue_get` - queue_get\n\* `queue_clear` - queue_clear' + 'Command method to execute on the agent server\n\n\* `user_message` - user_message\n\* `cancel` - cancel\n\* `close` - close\n\* `permission_response` - permission_response\n\* `set_config_option` - set_config_option\n\* `mcp_response` - mcp_response\n\* `pi\/rpc` - pi\/rpc\n\* `queue_get` - queue_get\n\* `queue_clear` - queue_clear\n\* `side_question` - side_question' ), params: zod.record(zod.string(), zod.unknown()).optional().describe('Parameters for the command'), id: zod.unknown().optional().describe('Optional JSON-RPC request ID (string or number)'), diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 333ecb97338d..930e37bcb920 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -77456,6 +77456,7 @@ export namespace Schemas { * * `pi/rpc` - pi/rpc * * `queue_get` - queue_get * * `queue_clear` - queue_clear + * * `side_question` - side_question */ export type TaskRunCommandRequestMethodEnum = typeof TaskRunCommandRequestMethodEnum[keyof typeof TaskRunCommandRequestMethodEnum]; @@ -77470,6 +77471,7 @@ export namespace Schemas { PiRpc: 'pi/rpc', QueueGet: 'queue_get', QueueClear: 'queue_clear', + SideQuestion: 'side_question', } as const; /** @@ -77490,7 +77492,8 @@ export namespace Schemas { * * `mcp_response` - mcp_response * * `pi/rpc` - pi/rpc * * `queue_get` - queue_get - * * `queue_clear` - queue_clear */ + * * `queue_clear` - queue_clear + * * `side_question` - side_question */ method: TaskRunCommandRequestMethodEnum; /** Parameters for the command */ params?: TaskRunCommandRequestParams; From 362632e04fb41fb61a20d783d93eb697a7e0d22a Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Sun, 16 Aug 2026 15:20:02 -0700 Subject: [PATCH 2/3] fix(tasks): return the standard code-access 403 for side_question The gate built its own 403 instead of calling code_access_required_response, so the body omitted the type: permission_denied field every other code-access denial in this viewset carries, and its message said "PostHog Code" where the endpoint's own 403 description says "PostHog Desktop". The proxy test now sends a padded question and asserts the forwarded payload, covering the serializer's strip, and the access test asserts the gate short-circuits before the agent-server call. Claude-Session: https://claude.ai/code/session_01WyZWyiJbQUFSgCfWyM3bCR --- products/tasks/backend/presentation/views/api.py | 13 ++++--------- products/tasks/backend/tests/test_api.py | 14 ++++++++++++-- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/products/tasks/backend/presentation/views/api.py b/products/tasks/backend/presentation/views/api.py index 3942cc4dedf2..d36c40cbb210 100644 --- a/products/tasks/backend/presentation/views/api.py +++ b/products/tasks/backend/presentation/views/api.py @@ -2159,15 +2159,10 @@ def command(self, request, pk=None, **kwargs): request_id = request.validated_data.get("id") params = request.validated_data.get("params") - # Unlike user_message (deliberately ungated below so Inbox "Discuss" works), - # side_question is only surfaced by the Desktop client, so it can require Code access. - if method == "side_question" and not tasks_access.has_tasks_access(request.user): - return Response( - TaskRunErrorResponseSerializer( - {"error": "side_question requires PostHog Code access.", "code": "code_access_required"} - ).data, - status=status.HTTP_403_FORBIDDEN, - ) + # A side question drives the agent and spends model tokens on the caller's behalf. Unlike + # user_message below, it has no Inbox surface to exempt, so every caller takes the gate. + if method == "side_question" and (access_response := code_access_required_response(request.user)): + return access_response if method == "user_message": # The Inbox starts interactive runs and drops the user straight into this composer, diff --git a/products/tasks/backend/tests/test_api.py b/products/tasks/backend/tests/test_api.py index 3bab10615858..f5496bccf050 100644 --- a/products/tasks/backend/tests/test_api.py +++ b/products/tasks/backend/tests/test_api.py @@ -9834,12 +9834,20 @@ def test_command_proxies_side_question(self, mock_post): response = self.client.post( self._command_url(task, run), - {"jsonrpc": "2.0", "method": "side_question", "params": {"question": "what does it do?"}, "id": "req-btw"}, + { + "jsonrpc": "2.0", + "method": "side_question", + "params": {"question": " what does it do? "}, + "id": "req-btw", + }, format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.json()["result"]["answer"], "It parses JSONL.") + forwarded = mock_post.call_args[1]["json"] + self.assertEqual(forwarded["method"], "side_question") + self.assertEqual(forwarded["params"]["question"], "what does it do?") @parameterized.expand([("blank", " "), ("empty", ""), ("missing", None)]) def test_command_rejects_side_question_without_a_question(self, _name, question): @@ -9855,7 +9863,8 @@ def test_command_rejects_side_question_without_a_question(self, _name, question) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - def test_command_side_question_requires_code_access(self): + @patch("products.tasks.backend.presentation.views.api.http_requests.post") + def test_command_side_question_requires_code_access(self, mock_post): self.set_tasks_feature_flag(False) task = self.create_task() run = self._create_run_with_sandbox(task) @@ -9868,6 +9877,7 @@ def test_command_side_question_requires_code_access(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.json()["code"], "code_access_required") + mock_post.assert_not_called() def test_empty_task_session_returns_read_only_storage_access(self): task = self.create_task(runtime=Task.Runtime.PI) From 723a2b93e258a157706fa26315f6c5de6e91addb Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Tue, 18 Aug 2026 16:20:13 +0100 Subject: [PATCH 3/3] feat(tasks): bound side_question by usage limit and question length The entitlement gate alone let a team over its usage limit, or in a deactivated org, keep spending model tokens through side_question, while user_message returned 429 in the same situation. The endpoint already documented a 429, so the gate now pairs code_access_required_response with usage_limit_response the way the rest of this viewset does. Question length is the direct lever on what a side question costs, so it takes a 10,000 character cap, following MAX_MCP_RESPONSE_PARAMS_BYTES. The three rejection cases move into test_command_rejects_invalid_payloads, where every other method's validation cases live, and gain an oversized-question row alongside them. Claude-Session: https://claude.ai/code/session_01WyZWyiJbQUFSgCfWyM3bCR --- .../tasks/backend/presentation/serializers.py | 11 +++++- .../tasks/backend/presentation/views/api.py | 9 +++-- products/tasks/backend/tests/test_api.py | 37 ++++++++++++++----- 3 files changed, 44 insertions(+), 13 deletions(-) diff --git a/products/tasks/backend/presentation/serializers.py b/products/tasks/backend/presentation/serializers.py index 68365addfb50..4227865856fa 100644 --- a/products/tasks/backend/presentation/serializers.py +++ b/products/tasks/backend/presentation/serializers.py @@ -2999,6 +2999,10 @@ class TaskRunCommandRequestSerializer(serializers.Serializer): # verbatim and never persisted or captured — they carry data from the user's private systems. MAX_MCP_RESPONSE_PARAMS_BYTES = 300_000 + # A side question is forwarded into an agent turn, so its length is the direct lever on + # model spend. Generous for a one-shot question, far under the request body cap. + MAX_SIDE_QUESTION_CHARS = 10_000 + jsonrpc = serializers.ChoiceField( choices=["2.0"], help_text="JSON-RPC version, must be '2.0'", @@ -3066,7 +3070,12 @@ def validate(self, attrs): ) elif method == "side_question": self._require_nonempty_string(params, "question") - params["question"] = params["question"].strip() + question = params["question"].strip() + if len(question) > self.MAX_SIDE_QUESTION_CHARS: + raise serializers.ValidationError( + {"params": f"question must be at most {self.MAX_SIDE_QUESTION_CHARS} characters"} + ) + params["question"] = question elif method == "pi/rpc": command = params.get("command") if not isinstance(command, dict): diff --git a/products/tasks/backend/presentation/views/api.py b/products/tasks/backend/presentation/views/api.py index d36c40cbb210..e55a381da9cf 100644 --- a/products/tasks/backend/presentation/views/api.py +++ b/products/tasks/backend/presentation/views/api.py @@ -2160,9 +2160,12 @@ def command(self, request, pk=None, **kwargs): params = request.validated_data.get("params") # A side question drives the agent and spends model tokens on the caller's behalf. Unlike - # user_message below, it has no Inbox surface to exempt, so every caller takes the gate. - if method == "side_question" and (access_response := code_access_required_response(request.user)): - return access_response + # user_message below, it has no Inbox surface to exempt, so every caller takes both gates. + if method == "side_question": + if access_response := code_access_required_response(request.user): + return access_response + if limit_response := usage_limit_response(request.user, self.team_id): + return limit_response if method == "user_message": # The Inbox starts interactive runs and drops the user straight into this composer, diff --git a/products/tasks/backend/tests/test_api.py b/products/tasks/backend/tests/test_api.py index f5496bccf050..b19cf0b5f7da 100644 --- a/products/tasks/backend/tests/test_api.py +++ b/products/tasks/backend/tests/test_api.py @@ -9849,23 +9849,26 @@ def test_command_proxies_side_question(self, mock_post): self.assertEqual(forwarded["method"], "side_question") self.assertEqual(forwarded["params"]["question"], "what does it do?") - @parameterized.expand([("blank", " "), ("empty", ""), ("missing", None)]) - def test_command_rejects_side_question_without_a_question(self, _name, question): + @patch("products.tasks.backend.presentation.views.api.http_requests.post") + def test_command_side_question_requires_code_access(self, mock_post): + self.set_tasks_feature_flag(False) task = self.create_task() run = self._create_run_with_sandbox(task) - params = {} if question is None else {"question": question} response = self.client.post( self._command_url(task, run), - {"jsonrpc": "2.0", "method": "side_question", "params": params, "id": "req-btw"}, + {"jsonrpc": "2.0", "method": "side_question", "params": {"question": "why?"}, "id": "req-btw"}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.json()["code"], "code_access_required") + mock_post.assert_not_called() @patch("products.tasks.backend.presentation.views.api.http_requests.post") - def test_command_side_question_requires_code_access(self, mock_post): - self.set_tasks_feature_flag(False) + @patch("products.tasks.backend.logic.services.code_usage_gate.get_posthog_code_usage") + def test_command_side_question_respects_usage_limit(self, mock_gate, mock_post): + mock_gate.return_value = OVER_LIMIT task = self.create_task() run = self._create_run_with_sandbox(task) @@ -9875,8 +9878,8 @@ def test_command_side_question_requires_code_access(self, mock_post): format="json", ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(response.json()["code"], "code_access_required") + self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS) + self.assertEqual(response.json()["code"], "usage_limit_exceeded") mock_post.assert_not_called() def test_empty_task_session_returns_read_only_storage_access(self): @@ -10570,6 +10573,22 @@ def test_command_fails_without_sandbox_url(self): }, }, ), + ( + "side_question_blank_question", + {"jsonrpc": "2.0", "method": "side_question", "params": {"question": " "}}, + ), + ( + "side_question_empty_question", + {"jsonrpc": "2.0", "method": "side_question", "params": {"question": ""}}, + ), + ( + "side_question_missing_question", + {"jsonrpc": "2.0", "method": "side_question", "params": {}}, + ), + ( + "side_question_oversized_question", + {"jsonrpc": "2.0", "method": "side_question", "params": {"question": "x" * 10_001}}, + ), ] ) def test_command_rejects_invalid_payloads(self, _name, payload):