Compare commits

...

3 Commits

Author SHA1 Message Date
SabhyaC26 998d2fb48d fix(pi-native): make dropped-followup error actionable with id + preview
When the inbox poller hits MAX_DELIVER_ATTEMPTS it still posts a
non-terminal error item, but the message was generic. Include the dropped
message's id, the attempt count, and a truncated (~80 char) content
preview so an operator can identify what was lost. Behavior (non-terminal
error item + unlink) is unchanged; full dead-letter handling is a
separate follow-up.

Co-authored-by: Isaac
2026-06-18 21:14:42 +00:00
SabhyaC26 075b89cd8e test(pi-native): cover delivery cap as non-terminal event
Add a Node-backed extension test that drives the real pi-native inbox poller through five failed follow-up delivery attempts. The test pins the F17 behavior: the payload is unlinked, an informational conversation item is emitted, and no terminal failed session status is posted.
2026-06-18 20:51:12 +00:00
SabhyaC26 dd96b87f26 fix(pi-native): don't terminate session when inbox delivery cap is hit
When MAX_DELIVER_ATTEMPTS is exhausted, the inbox poller posted an
external_session_status with status "failed". The runner treats that as
an authoritative terminal turn/sub-agent failure: it fans
session.status=failed to the parent and wakes it with a fabricated
"native sub-agent turn failed" result, killing a live session over a
transient, recoverable delivery hiccup (audit finding F17).

Instead, surface the dropped follow-up as a non-terminal informational
"error" conversation item (operator-visible banner, excluded from the
agent's LLM context) and unlink the inbox file. The session stays
running.

Note: the audit's Option A sketch uses role "system", but MessageData
only allows user/assistant roles and external_conversation_item requires
item_type/item_data, so the error item type is the schema-valid
non-terminal note channel.
2026-06-18 20:45:16 +00:00
2 changed files with 176 additions and 4 deletions
@@ -203,12 +203,34 @@ function startInboxPoller(pi, config, handleInterrupt) {
deliverAttempts.set(key, attempts);
continue;
}
// Cap reached: surface a failure (a silent drop would be invisible)
// and consume the file to stop the spin.
// Cap reached: surface the dropped follow-up without faking a turn
// failure. The runner treats external_session_status:failed as
// terminal for native sub-agents, so use a non-content conversation
// error item and consume the file to stop the spin. Include the
// message id and a short content preview so an operator can identify
// what was lost (data loss; the file is unlinked below).
deliverAttempts.delete(key);
const droppedId = id ?? "(no id)";
const preview =
typeof payload.content === "string"
? payload.content.length > 80
? `${payload.content.slice(0, 80)}`
: payload.content
: "";
postEvent(config, {
type: "external_session_status",
data: { status: "failed", response_id: `pi-deliver-failed-${Date.now()}` },
type: "external_conversation_item",
data: {
response_id: `pi-deliver-dropped-${Date.now()}`,
item_type: "error",
item_data: {
source: "execution",
code: "pi_followup_delivery_dropped",
message:
`Omnigent: a queued follow-up message (id ${droppedId}) could ` +
`not be delivered to Pi after ${MAX_DELIVER_ATTEMPTS} attempts ` +
`and was dropped. Content preview: ${JSON.stringify(preview)}`,
},
},
});
try {
fs.unlinkSync(fullPath);
+150
View File
@@ -0,0 +1,150 @@
"""End-to-end tests for the generated pi-native bridge extension."""
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
import pytest
def test_delivery_cap_drops_followup_without_failed_session_status(
tmp_path: Path,
) -> None:
"""The extension must not terminal-fail a session when follow-up delivery caps.
This runs the real JavaScript extension under Node with a real inbox payload
and mocked Pi/fetch boundaries. Five consecutive ``sendUserMessage`` throws
should consume the inbox file and emit an informational conversation item,
never ``external_session_status`` with ``status: "failed"``.
"""
node = shutil.which("node")
if node is None:
pytest.skip("node is required for the pi-native extension e2e test")
extension_path = (
Path(__file__).resolve().parents[1]
/ "omnigent"
/ "resources"
/ "pi_native"
/ "omnigent_pi_native_extension.js"
)
script = r"""
const assert = require("assert").strict;
const fs = require("fs");
const path = require("path");
const extensionPath = process.argv[1];
const tmpDir = process.argv[2];
const inboxDir = path.join(tmpDir, "inbox");
const payloadPath = path.join(inboxDir, "000-msg.json");
const configPath = path.join(tmpDir, "config.json");
fs.mkdirSync(inboxDir, { recursive: true });
fs.writeFileSync(
payloadPath,
JSON.stringify({ id: "msg-1", type: "user_message", content: "follow up" }),
);
fs.writeFileSync(
configPath,
JSON.stringify({
serverUrl: "http://omnigent.test",
sessionId: "session-1",
inboxDir,
authHeaders: { authorization: "Bearer test" },
}),
);
process.env.OMNIGENT_PI_NATIVE_CONFIG = configPath;
const postedEvents = [];
global.fetch = async (_url, request) => {
postedEvents.push(JSON.parse(request.body));
return { ok: true };
};
let pollInbox = null;
global.setInterval = (fn, _ms) => {
pollInbox = fn;
return { fakeInterval: true };
};
const handlers = {};
const sendAttempts = [];
const pi = {
registerCommand() {},
on(eventName, handler) {
handlers[eventName] = handler;
},
sendUserMessage(content, options) {
sendAttempts.push({ content, options });
throw new Error("Pi is not ready");
},
};
require(extensionPath)(pi);
(async () => {
assert.equal(typeof handlers.session_start, "function");
await handlers.session_start({}, {
sessionManager: { getSessionId: () => "native-session-1" },
ui: { setTitle() {}, setStatus() {}, notify() {} },
});
assert.equal(typeof pollInbox, "function");
for (let attempt = 0; attempt < 5; attempt += 1) {
pollInbox();
}
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(
sendAttempts,
Array.from({ length: 5 }, () => ({
content: "follow up",
options: { deliverAs: "followUp" },
})),
);
assert.equal(fs.existsSync(payloadPath), false);
assert.equal(
postedEvents.some(
(event) =>
event.type === "external_session_status" &&
event.data &&
event.data.status === "failed",
),
false,
JSON.stringify(postedEvents),
);
const dropNote = postedEvents.find(
(event) =>
event.type === "external_conversation_item" &&
event.data &&
event.data.item_type === "error" &&
event.data.item_data &&
event.data.item_data.code === "pi_followup_delivery_dropped",
);
assert.ok(dropNote, JSON.stringify(postedEvents));
assert.equal(dropNote.data.item_data.source, "execution");
assert.match(dropNote.data.response_id, /^pi-deliver-dropped-/);
// The note must be actionable: include the dropped message id and a preview
// of its content so an operator can identify what was lost.
assert.match(dropNote.data.item_data.message, /msg-1/);
assert.match(dropNote.data.item_data.message, /follow up/);
})().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
"""
result = subprocess.run(
[node, "-e", script, str(extension_path), str(tmp_path)],
capture_output=True,
check=False,
text=True,
timeout=10,
)
assert result.returncode == 0, result.stdout + result.stderr