Skip to content

Commit 856ba09

Browse files
committed
fix(mobile): replay the gated turn's effective config on approval resume
The lite resume answered a HITL gate with a references-only body, so the SDK hydrated the referenced variant's HEAD revision. For a dirty run that is a different model and — the security-relevant half — a different tool-permission map than the gate was approved under. The runner now stamps the turn's post-hydration config onto the interaction row as data.parameters. Read it off the same row the references come from and send it inline, which suppresses hydration and reproduces the turn exactly. Emit the key ONLY when the stamped config is a non-empty object: an empty {} also suppresses hydration and would run an unconfigured agent. Rows without it (legacy, over-cap, pre-stamping runner) keep today's references-only path. Declare parameters on the interaction zod schema too — objects strip unknown keys by default, so an undeclared field would be silently dropped and the fix would no-op with everything green.
1 parent c428e34 commit 856ba09

5 files changed

Lines changed: 155 additions & 19 deletions

File tree

web/mobile/src/features/chat/useApprovalActions.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,16 @@ const sanitizeReferences = (
4242
return Object.keys(out).length > 0 ? out : null
4343
}
4444

45+
/** The gated turn's stamped effective config, or null when the row predates stamping. */
46+
const sanitizeParameters = (raw: unknown): Record<string, unknown> | null => {
47+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null
48+
const params = raw as Record<string, unknown>
49+
return Object.keys(params).length > 0 ? params : null
50+
}
51+
4552
/**
4653
* Approve/deny pending HITL gates from the phone — the lite resume path (plan M1.3):
47-
* fresh records → stamp `approval-responded` on the tail → ONE references-only invoke POST
54+
* fresh records → stamp `approval-responded` on the tail → ONE resume invoke POST
4855
* (`buildAgentResumeRequest`), fire-and-forget. No live SSE consumption: the response is
4956
* drained in the background and the tightened records poll repaints the transcript until the
5057
* turn settles (`phase` drops back to idle once no gate is pending).
@@ -96,8 +103,8 @@ export const useApprovalActions = ({
96103
if (stamped === messages) {
97104
throw new Error("This approval is no longer pending — refresh and retry.")
98105
}
99-
// The interaction row stores the run's role-keyed workflow references
100-
// the resolver hydrates config from them server-side (references-only body).
106+
// The interaction row stores the run's role-keyed workflow references and,
107+
// when the runner stamped it, the turn's effective config.
101108
const interactions = await queryInteractions({
102109
sessionId,
103110
projectId,
@@ -112,7 +119,13 @@ export const useApprovalActions = ({
112119
const matched = answeredId
113120
? withRefs.find((row) => row.token === answeredId)
114121
: undefined
115-
const references = sanitizeReferences((matched ?? withRefs[0])?.data?.references)
122+
const row = matched ?? withRefs[0]
123+
const references = sanitizeReferences(row?.data?.references)
124+
// Replay the turn's own config when the runner stamped it — references alone
125+
// hydrate the variant's HEAD, which is a different model and, worse, a
126+
// different tool-permission map than the gate was approved under. Rows without
127+
// it (legacy, over-cap, pre-restart runner) fall back to hydration silently.
128+
const parameters = sanitizeParameters(row?.data?.parameters)
116129
if (!references) {
117130
throw new Error(
118131
"This approval carries no workflow reference — answer on desktop.",
@@ -132,6 +145,7 @@ export const useApprovalActions = ({
132145
references,
133146
sessionId,
134147
messages: stamped,
148+
parameters,
135149
projectId,
136150
applicationId: references.application?.id ?? undefined,
137151
})

web/packages/agenta-chat/src/transport/agentResumeRequest.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
/**
2-
* Lite agent resume request — the references-only invoke body for answering a HITL approval
3-
* without the hydrated workflow molecule (mobile, or any client that can't run the full
2+
* Lite agent resume request — the invoke body for answering a HITL approval without the
3+
* hydrated workflow molecule (mobile, or any client that can't run the full
44
* `buildAgentRequest` pipeline).
55
*
6-
* Load-bearing invariant: the body carries NO `data.parameters`. The SDK resolver hydrates the
7-
* config server-side ONLY when the request has `references` and no `data.parameters`
8-
* (`sdks/python/agenta/sdk/middlewares/running/resolver.py` `needs_reference_hydration`), so
9-
* emitting a `parameters` key — even empty — would skip hydration and run an unconfigured
10-
* draft. The unit test pins this.
6+
* Load-bearing invariant: the body carries NO `parameters` key unless we are deliberately
7+
* replaying the gated turn's stamped effective config (the interaction row's
8+
* `data.parameters`, written by the runner from the SDK's `effectiveParameters`). The SDK
9+
* resolver hydrates config server-side ONLY when the request has `references` and no
10+
* `data.parameters` (`sdks/python/agenta/sdk/middlewares/running/resolver.py`
11+
* `needs_reference_hydration`), so:
12+
* - non-empty stamped parameters -> emit them; hydration is skipped and the resume runs
13+
* under the exact config the gated turn ran under (including its tool permissions);
14+
* - absent / empty parameters -> emit NO key at all; an empty `{}` would suppress
15+
* hydration and run an unconfigured agent, which is worse than the wrong revision.
16+
* The unit tests pin both directions.
1117
*/
1218

1319
/** A `{id, slug, version}` platform reference (values may be partial). */
@@ -26,6 +32,9 @@ export interface AgentResumeRequestArgs {
2632
sessionId: string
2733
/** The full v6 UIMessage history with the approval decision stamped on the tail. */
2834
messages: unknown[]
35+
/** The gated turn's stamped effective config (interaction row `data.parameters`). Sent
36+
* inline to replay that exact config; omit/empty falls back to reference hydration. */
37+
parameters?: Record<string, unknown> | null
2938
/** ALWAYS rides the query string — the invoke routing middleware reads it for cookie-auth
3039
* permission checks (auth.py). Do not copy desktop's Authorization-gated omission. */
3140
projectId?: string
@@ -38,10 +47,19 @@ export interface AgentResumeRequest {
3847
requestBody: {
3948
session_id: string
4049
references: Record<string, AgentResumeReference> | null
41-
data: {inputs: {messages: unknown[]}}
50+
data: {inputs: {messages: unknown[]}; parameters?: Record<string, unknown>}
4251
}
4352
}
4453

54+
/** A stamped config is replayable only if it is a plain object with at least one key. */
55+
const hasStampedConfig = (
56+
parameters: Record<string, unknown> | null | undefined,
57+
): parameters is Record<string, unknown> =>
58+
!!parameters &&
59+
typeof parameters === "object" &&
60+
!Array.isArray(parameters) &&
61+
Object.keys(parameters).length > 0
62+
4563
const withQuery = (url: string, params: Record<string, string | undefined>): string => {
4664
const qs = new URLSearchParams()
4765
for (const [key, value] of Object.entries(params)) {
@@ -51,12 +69,13 @@ const withQuery = (url: string, params: Record<string, string | undefined>): str
5169
return suffix ? `${url}${url.includes("?") ? "&" : "?"}${suffix}` : url
5270
}
5371

54-
/** Compose the references-only resume invoke request (see module docstring). */
72+
/** Compose the resume invoke request (see module docstring). */
5573
export const buildAgentResumeRequest = ({
5674
invocationUrl,
5775
references,
5876
sessionId,
5977
messages,
78+
parameters,
6079
projectId,
6180
applicationId,
6281
}: AgentResumeRequestArgs): AgentResumeRequest => ({
@@ -73,6 +92,8 @@ export const buildAgentResumeRequest = ({
7392
requestBody: {
7493
session_id: sessionId,
7594
references,
76-
data: {inputs: {messages}},
95+
// Spread, never assign: an explicit `parameters: undefined` still creates the key,
96+
// and `JSON.stringify` dropping it is not enough for a structural caller.
97+
data: {inputs: {messages}, ...(hasStampedConfig(parameters) ? {parameters} : {})},
7798
},
7899
})

web/packages/agenta-chat/tests/unit/transport/agentResumeRequest.test.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,41 @@ const baseArgs = {
1010
}
1111

1212
describe("buildAgentResumeRequest", () => {
13-
it("never emits a data.parameters key (references-only server-side hydration)", () => {
14-
const req = buildAgentResumeRequest(baseArgs)
15-
expect("parameters" in req.requestBody.data).toBe(false)
16-
// Belt-and-braces: the serialized wire body must not carry the key either.
17-
expect(JSON.stringify(req.requestBody)).not.toContain('"parameters"')
13+
// The hydration switch: a `parameters` key — even empty — makes the SDK resolver skip
14+
// reference hydration, so it must appear ONLY when we have a real config to replay.
15+
describe("data.parameters (hydration switch)", () => {
16+
it("omits the key entirely when no parameters are supplied", () => {
17+
const req = buildAgentResumeRequest(baseArgs)
18+
expect("parameters" in req.requestBody.data).toBe(false)
19+
// Belt-and-braces: the serialized wire body must not carry the key either.
20+
expect(JSON.stringify(req.requestBody)).not.toContain('"parameters"')
21+
})
22+
23+
it.each([
24+
["undefined", undefined],
25+
["null", null],
26+
["an empty object", {}],
27+
])(
28+
"omits the key when parameters is %s (an empty {} would run unconfigured)",
29+
(_label, parameters) => {
30+
const req = buildAgentResumeRequest({...baseArgs, parameters})
31+
expect("parameters" in req.requestBody.data).toBe(false)
32+
expect(JSON.stringify(req.requestBody)).not.toContain('"parameters"')
33+
},
34+
)
35+
36+
it("emits the stamped effective config verbatim when it is non-empty", () => {
37+
const parameters = {agent: {llm: {model: "anthropic/claude-sonnet-4-5"}}}
38+
const req = buildAgentResumeRequest({...baseArgs, parameters})
39+
expect("parameters" in req.requestBody.data).toBe(true)
40+
expect(req.requestBody.data.parameters).toBe(parameters)
41+
})
42+
43+
it("still sends references alongside inline parameters", () => {
44+
const req = buildAgentResumeRequest({...baseArgs, parameters: {agent: {}}})
45+
expect(req.requestBody.references).toEqual({workflow_revision: {id: "rev-1"}})
46+
expect(req.requestBody.data.inputs.messages).toBe(baseArgs.messages)
47+
})
1848
})
1949

2050
it("carries the session id, references, and messages under data.inputs", () => {

web/packages/agenta-entities/src/session/core/schema.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ export const sessionInteractionSchema = z.object({
6060
.object({
6161
request: z.record(z.string(), z.unknown()).nullish(),
6262
references: z.record(z.string(), z.unknown()).nullish(),
63+
// The gated turn's stamped effective config; must be declared or zod's default
64+
// strip-unknown-keys would silently drop it and the resume falls back to
65+
// reference hydration (i.e. the wrong config). Rows written before the runner
66+
// started stamping simply have no key.
67+
parameters: z.record(z.string(), z.unknown()).nullish(),
6368
selector: z.record(z.string(), z.unknown()).nullish(),
6469
resolution: z.record(z.string(), z.unknown()).nullish(),
6570
})
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* Pins the interaction row's `data.parameters` — the gated turn's stamped effective config.
3+
* zod objects strip unknown keys by default, so an undeclared field validates fine and
4+
* arrives as `undefined`: the resume would silently fall back to reference hydration and run
5+
* the committed config (wrong model, wrong tool permissions) with tsc and tests all green.
6+
* These assert the field survives parsing, and that legacy rows without it still parse.
7+
*/
8+
import {describe, expect, it} from "vitest"
9+
10+
import {
11+
sessionInteractionSchema,
12+
sessionInteractionsResponseSchema,
13+
} from "../../src/session/core/schema"
14+
15+
const effectiveParameters = {
16+
agent: {
17+
llm: {model: "anthropic/claude-sonnet-4-5", provider: "anthropic"},
18+
runner: {kind: "sidecar", permissions: {default: "allow_reads"}},
19+
},
20+
}
21+
22+
const wireInteraction = {
23+
id: "int-1",
24+
session_id: "sess-1",
25+
turn_id: "turn-1",
26+
token: "tok-1",
27+
kind: "user_approval",
28+
status: "pending",
29+
created_at: "2026-07-29T00:00:00Z",
30+
data: {
31+
request: {tool: "Bash", args: {command: "echo hi"}},
32+
references: {workflow: {id: "wf-1", slug: "agent"}},
33+
parameters: effectiveParameters,
34+
},
35+
}
36+
37+
describe("sessionInteractionSchema", () => {
38+
it("keeps data.parameters (the stamped effective config) verbatim", () => {
39+
const out = sessionInteractionSchema.parse(wireInteraction)
40+
expect(out.data?.parameters).toEqual(effectiveParameters)
41+
})
42+
43+
it("keeps request and references alongside parameters", () => {
44+
const out = sessionInteractionSchema.parse(wireInteraction)
45+
expect(out.data?.references).toEqual({workflow: {id: "wf-1", slug: "agent"}})
46+
expect(out.data?.request).toEqual({tool: "Bash", args: {command: "echo hi"}})
47+
})
48+
49+
it("parses a legacy row that carries no parameters (pre-stamping runner)", () => {
50+
const legacy = {
51+
...wireInteraction,
52+
data: {references: {workflow: {id: "wf-1"}}},
53+
}
54+
const out = sessionInteractionSchema.parse(legacy)
55+
expect(out.data?.parameters).toBeUndefined()
56+
expect(out.data?.references).toEqual({workflow: {id: "wf-1"}})
57+
})
58+
59+
it("carries parameters through the query response envelope", () => {
60+
const out = sessionInteractionsResponseSchema.parse({
61+
count: 1,
62+
interactions: [wireInteraction],
63+
})
64+
expect(out.interactions?.[0].data?.parameters).toEqual(effectiveParameters)
65+
})
66+
})

0 commit comments

Comments
 (0)