Files
PaperClipAI/server/src/__tests__/plugin-agent-sessions.test.ts
T
Michael NguyenandPaperclip caae2778f0 fix: deliver plugin agent session turns and replies (#10137)
## 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>
2026-07-23 20:52:42 -07:00

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();
});
});