mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-12 08:19:54 +02:00
## Thinking Path > - Paperclip manages agent execution through heartbeat runs and adapter-specific sessions > - Plugins can open an agent session and send a conversational message through the host service > - The host previously stored that message only in opaque wake payload metadata, so local adapters never saw it in their CLI prompt > - The host also forwarded run log chunks but did not expose the persisted final assistant text as the session reply > - This pull request defines both sides of the session contract in the shared wake renderer and terminal run event > - The benefit is that local adapters receive the actual conversational turn and plugins receive one canonical final reply ## Linked Issues or Issue Description Related context: Refs #629 and Refs #2880 describe adjacent `claude_local` final-text visibility failures. They concern issue comments rather than plugin agent sessions, but exercise the same need for a canonical persisted run summary. Companion consumer change: paperclipai/paperclip-gateway#3. Bug description: - **Observed:** calling the plugin host's `agents.sessions.sendMessage()` with `prompt: "hello"` woke a `claude_local` agent, but the generated CLI prompt omitted `hello`. On completion, the session emitted log chunks and a generic `Run completed` done event, so callers could not reliably recover the assistant reply. - **Expected:** the prompt becomes the user-supplied conversational turn for that agent session, and the successful terminal event carries the run's canonical final user-facing assistant text. - **Reproduction:** create a plugin agent session for a local adapter, call `sendMessage()` with a non-empty prompt, inspect the adapter prompt and terminal session event. - **Affected baseline:** `b517b887a` on `master`, local trusted deployment with plugin host services and `claude_local`; `codex_local` shared the wake-rendering gap because both use the common Paperclip wake prompt renderer. ## What Changed - Added a typed `agentMessage` wake payload rendered by the shared adapter prompt path used by `claude_local`, `codex_local`, and other local adapters. - Labeled session content as user-supplied and explicitly non-authoritative: it cannot expand authorization, permissions, task scope, or company boundaries. - Preserved ordinary heartbeat behavior by omitting the section when no agent-session message exists. - Added canonical `finalText` to terminal heartbeat status events from the already-persisted run summary/result/message. - Defined successful `AgentSessionEvent.message` as the canonical final user-facing reply (or `null`) and forwarded it on the terminal `done` event. - Added host, wake-renderer, normal-heartbeat, and terminal-reply regression coverage. ## Verification - `pnpm exec vitest run packages/adapter-utils/src/server-utils.test.ts server/src/__tests__/heartbeat-agent-session-message.test.ts server/src/__tests__/heartbeat-run-status-payload.test.ts server/src/__tests__/plugin-agent-sessions.test.ts server/src/__tests__/heartbeat-run-summary.test.ts` — 87 passed. - `pnpm -r typecheck` — passed across all 31 workspaces. - `pnpm build` — passed. - `pnpm test:run` — 2,860 passed, 1 skipped, 3 unrelated failures: two existing macOS temp-path alias assertions (`/tmp` vs `/private/tmp`) in workspace branch-containment tests and one reproducible auto-port runtime-service adoption failure. The same three failures reproduce when the two files run alone; none touch this change. - Live Slack verification intentionally remains operator-gated because it requires rebuilding/restarting the host. ## Risks - User-controlled chat text now reaches the model prompt, which is an intentional prompt-injection surface. The renderer labels it as untrusted conversational content, while the existing plugin/session company checks and caller authorization remain unchanged. - `finalText` is added to company-scoped heartbeat status events. It is derived from the same persisted summary/result/message already used for run comments; no raw stdout or secrets are added. - Consumers that ignore the new field remain compatible, and successful runs without usable final text still emit `message: null`. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex (GPT-5), agentic reasoning with repository/tool use and code execution; context-window size is not surfaced in this environment. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
114 lines
2.8 KiB
TypeScript
114 lines
2.8 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { publishLiveEvent } from "../services/live-events.js";
|
|
|
|
const mockWakeup = vi.hoisted(() => vi.fn());
|
|
const mockHeartbeatService = vi.hoisted(() => vi.fn(() => ({ wakeup: mockWakeup })));
|
|
|
|
vi.mock("../services/heartbeat.js", () => ({
|
|
heartbeatService: mockHeartbeatService,
|
|
}));
|
|
|
|
import { buildHostServices } from "../services/plugin-host-services.js";
|
|
|
|
function createEventBusStub() {
|
|
return {
|
|
forPlugin() {
|
|
return {
|
|
emit: async () => {},
|
|
subscribe: () => {},
|
|
clear: () => {},
|
|
};
|
|
},
|
|
} as any;
|
|
}
|
|
|
|
function createSessionLookupDb(session: {
|
|
id: string;
|
|
companyId: string;
|
|
agentId: string;
|
|
taskKey: string;
|
|
}) {
|
|
const query = {
|
|
from: () => query,
|
|
where: () => query,
|
|
then: (resolve: (rows: typeof session[]) => unknown) => Promise.resolve(resolve([session])),
|
|
};
|
|
return {
|
|
select: () => query,
|
|
} as never;
|
|
}
|
|
|
|
describe("plugin agent sessions", () => {
|
|
it("delivers the message body in wake context and returns final assistant text on done", async () => {
|
|
const companyId = "company-1";
|
|
const agentId = "agent-1";
|
|
const sessionId = "session-1";
|
|
const notifyWorker = vi.fn();
|
|
mockWakeup.mockReset();
|
|
mockWakeup.mockResolvedValue({ id: "run-1" });
|
|
|
|
const services = buildHostServices(
|
|
createSessionLookupDb({
|
|
id: sessionId,
|
|
companyId,
|
|
agentId,
|
|
taskKey: "plugin:paperclip.gateway:session:session-1",
|
|
}),
|
|
"plugin-record-id",
|
|
"paperclip.gateway",
|
|
createEventBusStub(),
|
|
notifyWorker,
|
|
);
|
|
|
|
await expect(
|
|
services.agentSessions.sendMessage({
|
|
sessionId,
|
|
companyId,
|
|
prompt: "hello",
|
|
reason: "gateway_chat_message",
|
|
}),
|
|
).resolves.toEqual({ runId: "run-1" });
|
|
|
|
expect(mockWakeup).toHaveBeenCalledWith(
|
|
agentId,
|
|
expect.objectContaining({
|
|
payload: { prompt: "hello" },
|
|
contextSnapshot: {
|
|
taskKey: "plugin:paperclip.gateway:session:session-1",
|
|
wakeReason: "gateway_chat_message",
|
|
wakeSource: "automation",
|
|
wakeTriggerDetail: "system",
|
|
paperclipAgentMessage: {
|
|
text: "hello",
|
|
source: "plugin_session",
|
|
pluginKey: "paperclip.gateway",
|
|
sessionId,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
publishLiveEvent({
|
|
companyId,
|
|
type: "heartbeat.run.status",
|
|
payload: {
|
|
runId: "run-1",
|
|
status: "succeeded",
|
|
finalText: "Hello! How can I help?",
|
|
},
|
|
});
|
|
|
|
expect(notifyWorker).toHaveBeenCalledWith(
|
|
"agents.sessions.event",
|
|
expect.objectContaining({
|
|
sessionId,
|
|
runId: "run-1",
|
|
eventType: "done",
|
|
message: "Hello! How can I help?",
|
|
}),
|
|
);
|
|
|
|
services.dispose();
|
|
});
|
|
});
|