mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-17 10:06:42 +02:00
fix(runner): bind ACPX steering to the active provider turn
Preserve flat Runner acknowledgement receipts so native steering emits one transcript item. Add a pinned Hermes regression across Rust PRP with distinct durable and provider turn identities and stale-control rejection. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
638296ab2a
commit
3e9e54964c
3 files changed
+76
-2
No files matched your search
@@ -1673,3 +1673,27 @@ Fresh cloud materialization, current-head native checks and PR verification are
|
||||
required. No local Docker or Rust build is used. Shared configuration adoption,
|
||||
routine completion and transcript UI patches remain unapplied outside the fixed
|
||||
PR file set. Hermes remains pending the full release qualification matrix.
|
||||
|
||||
### 2026-10-08 active steering through the production transport
|
||||
|
||||
The actual browser journey negotiated Hermes steering and exposed the Steer
|
||||
action, but the production transport rejected its current target as stale.
|
||||
ACPX validates controls against the active provider turn; the transport had
|
||||
sent the durable Paperclip turn that scopes PRP and semantic events. It now
|
||||
sends the active provider identity for ACPX controls. Other provider mappings
|
||||
retain their existing contract.
|
||||
|
||||
The focused regression crosses TypeScript, Rust PRP, the production sidecar,
|
||||
ACPX and pinned Hermes with distinct durable and provider turn IDs. It first
|
||||
reproduced the stale-turn rejection. After that correction it exposed duplicate
|
||||
steering acknowledgements: item rehydration wrapped the flat Runner receipt,
|
||||
so the driver could not recognize its transport echo. Preserving that flat
|
||||
receipt retains durable evidence and emits one bound transcript acknowledgement.
|
||||
|
||||
The regression now passes with the verified cloud-built Mac Runner. It verifies
|
||||
that the native agent receives the correction, the active request is interrupted,
|
||||
exactly one provider turn and acknowledgement remain, semantic completion
|
||||
succeeds, and a control after settlement is refused. This is credential-free
|
||||
native transport evidence. The earlier browser and regression failures remain
|
||||
failed; a fresh browser acceptance run and cloud checks remain required. No
|
||||
local Docker or Rust build is used.
|
||||
@@ -39,8 +39,11 @@ const nativeResponse = {
|
||||
const cases = [
|
||||
...[null, 'ask_user_questions', 'request_confirmation', 'request_checkbox_confirmation'].map(interactionKind => ({ interactionKind, nativeQuestionAction: null })),
|
||||
...['submit', 'cancel', 'stop'].map(nativeQuestionAction => ({ interactionKind: null, nativeQuestionAction })),
|
||||
{ interactionKind: null, nativeQuestionAction: null, nativeSteering: true },
|
||||
];
|
||||
for (const { interactionKind, nativeQuestionAction } of cases) test(nativeQuestionAction
|
||||
for (const { interactionKind, nativeQuestionAction, nativeSteering } of cases) test(nativeSteering
|
||||
? 'pinned Hermes accepts active provider-turn steering across the production Rust PRP transport'
|
||||
: nativeQuestionAction
|
||||
? `pinned Hermes native question batch crosses Rust PRP with ${nativeQuestionAction} and rejects a second answer`
|
||||
: interactionKind
|
||||
? `pinned Hermes retains prompt usage before committed ${interactionKind} triggers immediate Rust-sidecar shutdown`
|
||||
@@ -68,6 +71,22 @@ for (const { interactionKind, nativeQuestionAction } of cases) test(nativeQuesti
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
||||
const send = (delta, finish_reason = null) => res.write(`data: ${JSON.stringify({ id: 'native-prp', object: 'chat.completion.chunk', choices: [{ index: 0, delta, finish_reason }] })}\n\n`);
|
||||
if (nativeSteering && requests.length === 1) {
|
||||
send({ role: 'assistant', reasoning_content: 'Checking the native runner path.' });
|
||||
send({ content: 'NATIVE_RUNNER_STEER_READY' });
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (!res.destroyed && Date.now() < deadline) {
|
||||
res.write(': active native stream\n\n');
|
||||
await new Promise(resolve => setTimeout(resolve, 30));
|
||||
}
|
||||
assert.ok(res.destroyed, 'Native steering did not interrupt the current model request');
|
||||
return;
|
||||
}
|
||||
if (nativeSteering) {
|
||||
const latestUser = body.messages.findLast(message => message.role === 'user');
|
||||
assert.ok(JSON.stringify(latestUser?.content).includes('NATIVE_RUNNER_STEER_ACCEPTED'),
|
||||
'Steering must reach the original native loop as a user correction');
|
||||
}
|
||||
const toolResults = body.messages.filter(message => message.role === 'tool');
|
||||
const askNative = nativeQuestionAction && toolResults.length === 0;
|
||||
if (askNative || toolResults.length === (nativeQuestionAction ? 1 : 0)) {
|
||||
@@ -146,8 +165,19 @@ for (const { interactionKind, nativeQuestionAction } of cases) test(nativeQuesti
|
||||
let cancellationOutcome;
|
||||
let stoppedQuestionRequest;
|
||||
let questionResolutions = 0;
|
||||
let steeringTurnId;
|
||||
let steeringCount = 0;
|
||||
for await (const event of session.events()) {
|
||||
events.push(event);
|
||||
if (nativeSteering && event.eventType === 'item.delta'
|
||||
&& event.payload.kind === 'agentMessage' && event.payload.text.includes('NATIVE_RUNNER_STEER_READY')) {
|
||||
assert.equal(steeringCount++, 0, 'The active stream must not receive duplicate steering');
|
||||
steeringTurnId = event.turnId;
|
||||
assert.notEqual(steeringTurnId, 'hermes-prp-turn', 'Fixture must distinguish provider and durable PRP turn identities');
|
||||
assert.equal((await session.capabilities()).steering, true);
|
||||
await session.steer({ turnId: steeringTurnId, message: { role: 'user', text: 'NATIVE_RUNNER_STEER_ACCEPTED' },
|
||||
correlationId: 'native-steer-fixture' });
|
||||
}
|
||||
if (nativeQuestionAction && event.eventType === 'runtime_request.created') {
|
||||
const request = event.payload.request;
|
||||
assert.equal(request.origin.method, '_hermes/ask_questions');
|
||||
@@ -275,4 +305,17 @@ for (const { interactionKind, nativeQuestionAction } of cases) test(nativeQuesti
|
||||
assert.equal(snapshot.semanticResult?.reportedWorkDisposition, 'done');
|
||||
assert.equal(snapshot.terminal?.runTerminalState, 'succeeded');
|
||||
assert.equal(bundle.evidence().acpxAgent, 'hermes');
|
||||
if (nativeSteering) {
|
||||
assert.equal(steeringCount, 1);
|
||||
assert.equal(requests.length, 3, 'A redirected request and normal completion must remain in one native turn');
|
||||
assert.equal(events.filter(event => event.eventType === 'turn.started').length, 1);
|
||||
const acknowledgements = events.filter(event => event.eventType === 'item.completed'
|
||||
&& event.payload.kind === 'steering_acknowledgement');
|
||||
assert.equal(acknowledgements.length, 1, 'Steering must produce one canonical acknowledgement');
|
||||
assert.equal(acknowledgements[0].turnId, steeringTurnId);
|
||||
assert.equal(acknowledgements[0].payload.status, 'acknowledged');
|
||||
await assert.rejects(session.steer({ turnId: steeringTurnId,
|
||||
message: { role: 'user', text: 'STALE_NATIVE_STEER' }, correlationId: 'stale-native-steer' }), /terminal|active turn/i);
|
||||
}
|
||||
|
||||
});
|
||||
@@ -1926,6 +1926,11 @@ export function rehydrateRunnerdItemNotification(
|
||||
activeTurnId: string,
|
||||
): Record<string, unknown> {
|
||||
const rawItem = record(rawParams.item);
|
||||
if (rawParams.kind === "steering_acknowledgement" && Object.keys(rawItem).length === 0) {
|
||||
// Preserve runnerd's flat receipt so the driver recognizes the transport
|
||||
// echo. The acknowledged request emits the user-visible bound item once.
|
||||
return { ...rawParams, threadId: openedThreadId, turnId: activeTurnId };
|
||||
}
|
||||
const channel = rawItem.channel ?? rawParams.channel;
|
||||
const providerPhase = rawItem.phase ?? rawParams.providerPhase;
|
||||
const phase =
|
||||
@@ -3703,7 +3708,9 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
"turn.steer",
|
||||
{
|
||||
text,
|
||||
turnId: this.#durableTurnId,
|
||||
// ACPX owns the requested provider turn, which can differ from the
|
||||
// durable PRP turn that scopes control-plane and semantic events.
|
||||
turnId: this.options.provider === "acpx" ? expectedTurnId : this.#durableTurnId,
|
||||
providerTurnId: expectedTurnId,
|
||||
...(params.mode === "follow_up" ? { mode: "follow_up" } : {}),
|
||||
...(correlationId ? { correlationId } : {}),
|
||||
|
||||
Reference in new issue
Block a user