Files
PaperClipAI/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs
Nicky LeachandPaperclip af5c2d101c fix(paperclip-runner): deliver the shutdown settlement event past the terminal-turn gate (#14668)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The OpenCode driver maps provider events to runtime request events
> - A provider turn can end before a pending runtime request receives
its answer
> - The consumer reads one turn's events and stops at that turn's
terminal event
> - A settlement event that arrives after that terminal event never
reaches the consumer
> - This pull request settles the request inside its own turn, before
the terminal event
> - The benefit is reliable request settlement without weakening
late-frame protection

## Linked Issues or Issue Description

**What happened?**

A pending runtime request stayed open after an OpenCode turn failed
through `session.error`. Session shutdown then dropped its settlement
event as a late provider frame.

**Expected behavior**

The driver must deliver `runtime_request.expired` with the original
`turnId` and `itemId`, inside the same single read pass the consumer
performs on that turn.

**Steps to reproduce**

1. Start an OpenCode turn that creates a native runtime request.
2. Leave the request pending and fail the turn through `session.error`.
3. Close the session and inspect the emitted events.

**Paperclip version or commit**

`22d41c6081f05658e0d7c8485d0f22f35af4a79e`

**Deployment mode**

Built from source with the OpenCode driver test fixture.

## What Changed

- Settle a pending runtime request as soon as its own turn goes
terminal, before the terminal turn event.
- Add an optional `bypassTerminalTurnGate` parameter to the OpenCode
session emitter, and set it on the settlement emit.
- Keep a settlement loop in session close as a fallback for a request
whose turn never went terminal.
- Add a fixture trigger and a regression test that reads one turn in a
single pass.
- Keep the late provider frame gate unchanged for every provider event
path.

## Verification

- Run `pnpm vitest run
packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts`.
- Run `tsc -p tsconfig.json --noEmit` in `packages/paperclip-runner`.
- Run `tsc -p tsconfig.surfaces.json --noEmit` in
`packages/paperclip-runner`.
- Confirm that the regression test receives `runtime_request.expired`
with the original identifiers.
- Confirm that the late-frame tests still report dropped provider
frames.

## Risks

Four call sites now reach the settlement path: session close and the
three terminal turn paths (completed, cancelled, and failed). At the
three terminal turn paths the turn is still the active turn, so the gate
admits the settlement event with or without the parameter. Session close
is the only place where the parameter changes the result of the gate,
and only for a request whose turn already ended. Every provider event
path keeps the existing terminal-turn gate. The known driver test
failure is pre-existing and does not touch this change.

## Model Used

Claude Sonnet 5, with code execution and test assistance.

## 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
- [x] I have run the changed tests locally; the known pre-existing
failure remains documented above
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation, or no documentation change
applies
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I have addressed all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-30 09:48:14 -07:00

906 lines
29 KiB
JavaScript

#!/usr/bin/env node
import { createServer } from "node:http";
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
const args = process.argv.slice(2);
// Exercise diagnostic redaction without copying a real environment credential
// into stderr or the provider trace.
process.stderr.write("authorization=synthetic-redaction-sentinel\n");
const port = Number(args[args.indexOf("--port") + 1]);
const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode";
const password = process.env.OPENCODE_SERVER_PASSWORD ?? "";
const expectedAuth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
const session = { id: "ses_fake_1", title: "Paperclip fake" };
const clients = new Set();
let eventConnections = 0;
const runtimeConfig = JSON.parse(
await readFile(
join(process.env.XDG_CONFIG_HOME, "opencode", "opencode.json"),
"utf8",
),
);
const mcp = runtimeConfig.mcp?.paperclip;
let mcpRequestId = 1;
const mcpEvidence = { tools: [], calls: [] };
function nativeQuestion() {
return {
id: "question-native-1",
sessionID: session.id,
questions: [
{
id: "environment",
header: "Environment",
question: "Where should we deploy?",
options: [
{ label: "Staging", description: "Deploy to staging first." },
{
label: "Production",
description: "Deploy directly to production.",
},
],
custom: true,
},
{
id: "regions",
header: "Regions",
question: "Which regions?",
options: [{ label: "US" }, { label: "EU" }],
multiple: true,
},
],
};
}
function nativePermission(style) {
return style === "legacy"
? {
id: "permission-native-1",
type: "tool",
pattern: "echo OK",
sessionID: session.id,
messageID: "message-permission",
callID: "call-permission",
title: "Run validation command",
metadata: {},
time: { created: Date.now() },
}
: {
id: "permission-native-1",
sessionID: session.id,
permission: "bash",
patterns: ["echo OK"],
always: ["echo *"],
metadata: {},
tool: { messageID: "message-permission", callID: "call-permission" },
};
}
let pendingQuestion = await readFile(
join(process.env.XDG_DATA_HOME, "fake-pending-question.json"),
"utf8",
)
.then((value) => JSON.parse(value))
.catch(() => null);
let pendingPermission = await readFile(
join(process.env.XDG_DATA_HOME, "fake-pending-permission.json"),
"utf8",
)
.then((value) => JSON.parse(value))
.catch(() => null);
let permissionStyle = "v2";
await mkdir(process.env.XDG_DATA_HOME, { recursive: true });
await writeFile(
join(process.env.XDG_DATA_HOME, "fake-environment.json"),
JSON.stringify({
keys: Object.keys(process.env).sort(),
home: process.env.HOME,
configHome: process.env.XDG_CONFIG_HOME,
projectConfigDisabled: process.env.OPENCODE_DISABLE_PROJECT_CONFIG,
}),
);
function json(response, status, value) {
response.statusCode = status;
response.setHeader("Content-Type", "application/json");
response.end(JSON.stringify(value));
}
// Each simulated turn reuses the same short event ids ("event-1",
// "event-3", and so on). OpenCode's real event ids are unique, and the
// driver relies on that to drop a duplicate delivery of the same event.
// Scope every event id to the turn that produced it so a second turn's
// events do not look like duplicates of the first turn's events.
let promptTurnSeq = 0;
// Set by a "late-straggler-source" turn to simulate a distinct provider
// frame for that turn's message arriving only after a later turn's prompt
// has already been accepted. Counts down by one on each subsequent prompt
// request and flushes at the start of the request that brings it to zero.
// A plain "late-straggler-source" message flushes on the very next prompt
// (delay 1); "late-straggler-source-delay-N" flushes N prompts later, so a
// test can place the stale frame's arrival after several more turns have
// sealed.
let lateStragglerRemaining = 0;
function emit(value) {
const scoped =
promptTurnSeq > 0 && typeof value.id === "string"
? { ...value, id: `${value.id}#${promptTurnSeq}` }
: value;
const frame = `data: ${JSON.stringify(scoped)}\n\n`;
for (const response of clients) response.write(frame);
}
function parsedPromptText(promptBody) {
const text = promptBody.parts?.[0]?.text ?? "";
try {
return JSON.parse(text);
} catch {
return { message: text };
}
}
async function mcpRequest(method, params) {
const response = await fetch(mcp.url, {
method: "POST",
headers: {
...mcp.headers,
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
},
body: JSON.stringify({
jsonrpc: "2.0",
id: mcpRequestId++,
method,
...(params ? { params } : {}),
}),
});
return response.json();
}
async function callFirstPaperclipTool() {
if (!mcp?.url) return;
await mcpRequest("initialize", {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: { name: "fake-opencode", version: "1" },
});
const listed = await mcpRequest("tools/list");
mcpEvidence.tools = (listed.result?.tools ?? []).map((entry) => entry.name);
const tool = listed.result?.tools?.find(
(entry) =>
!["paperclip_finish", "paperclip_block"].includes(entry.name) &&
(entry.inputSchema?.required?.length ?? 0) === 0,
);
if (tool)
mcpEvidence.calls.push(
await mcpRequest("tools/call", {
name: `paperclip_${tool.name}`,
arguments: {},
}),
);
await writeFile(
join(process.env.XDG_DATA_HOME, "fake-mcp-evidence.json"),
JSON.stringify(mcpEvidence),
);
}
async function callTerminalTool(promptBody) {
const prompt = parsedPromptText(promptBody);
const blocked = String(prompt.message ?? "").includes("block-result");
const result = {
schema: "paperclip.run_result.v1",
reportedWorkDisposition: blocked ? "blocked" : "done",
summary: blocked
? "Fake provider is blocked."
: "Fake provider completed the task.",
completionClaim: {
contractRevision:
prompt.completionContract?.revision ?? prompt.task?.completionContract?.revision ?? "codex-demo-v1",
objectiveSatisfied: !blocked,
criteria: (prompt.completionContract?.criteria ?? prompt.task?.completionContract?.criteria ?? []).map(
(criterion) => ({
criterionId: criterion.id,
status: blocked ? "unknown" : "satisfied",
evidenceRefs: [],
}),
),
remainingWork: blocked
? [
{
description: "Waiting on a test dependency.",
blocksCompletion: true,
},
]
: [],
},
evidence: [],
verification: [],
attentionRequests: blocked
? [{ kind: "blocker", summary: "Waiting on a test dependency." }]
: [],
artifacts: [],
...(blocked
? {
blocker: {
reasonCode: "test_dependency",
owner: { kind: "external", name: "fixture" },
unblockAction: "Release the fixture dependency.",
scope: "current_track",
},
}
: {}),
};
if (String(prompt.message ?? "").includes("repair-criteria")) {
const bad = structuredClone(result);
bad.completionClaim.criteria = String(prompt.message).includes("repair-criteria-missing") ? []
: String(prompt.message).includes("repair-criteria-duplicate") ? [...result.completionClaim.criteria, ...result.completionClaim.criteria]
: [{ criterionId: "invented", status: "satisfied", evidenceRefs: [] }];
const rejected = await mcpRequest("tools/call", { name: "paperclip_finish", arguments: bad });
await writeFile(join(process.env.XDG_DATA_HOME, "fake-criteria-repair.json"), JSON.stringify(rejected));
}
return mcpRequest("tools/call", {
name: blocked ? "paperclip_block" : "paperclip_finish",
arguments: result,
});
}
const server = createServer(async (request, response) => {
if (request.headers.authorization !== expectedAuth)
return json(response, 401, { error: "unauthorized" });
if (request.url === "/global/health")
return json(response, 200, { healthy: true, version: "1.18.32" });
if (request.url === "/event") {
eventConnections += 1;
response.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
});
response.write(
`data: ${JSON.stringify({ type: "server.connected", properties: {} })}\n\n`,
);
if (eventConnections === 1) {
response.end();
return;
}
clients.add(response);
request.on("close", () => clients.delete(response));
return;
}
if (request.method === "POST" && request.url === "/session")
return json(response, 200, session);
if (request.method === "GET" && request.url === `/session/${session.id}`)
return json(response, 200, session);
if (
request.method === "GET" &&
request.url === `/session/${session.id}/message`
)
return json(response, 200, []);
if (request.method === "GET" && request.url === "/session/status")
return json(response, 200, { [session.id]: { type: "idle" } });
if (request.method === "GET" && request.url?.startsWith("/question?"))
return json(response, 200, pendingQuestion ? [pendingQuestion] : []);
if (request.method === "GET" && request.url?.startsWith("/permission?"))
return json(response, 200, pendingPermission ? [pendingPermission] : []);
if (
request.method === "POST" &&
request.url?.startsWith("/question/question-native-1/reply?")
) {
const chunks = [];
request.on("data", (chunk) => chunks.push(chunk));
request.on("end", async () => {
const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
await writeFile(
join(process.env.XDG_DATA_HOME, "fake-question-reply.json"),
JSON.stringify({ url: request.url, body }),
);
const repliedQuestion = pendingQuestion;
pendingQuestion = null;
emit({
type: "question.replied",
id: "event-question-replied",
properties: repliedQuestion,
});
setTimeout(() => {
json(response, 200, true);
emit({
type: "session.idle",
id: "event-question-idle",
properties: { sessionID: session.id },
});
}, 10);
});
return;
}
if (
request.method === "POST" &&
request.url?.startsWith("/question/question-native-1/reject?")
) {
const rejectedQuestion = pendingQuestion;
pendingQuestion = null;
emit({
type: "question.rejected",
id: "event-question-rejected",
properties: rejectedQuestion,
});
setTimeout(() => {
json(response, 200, true);
emit({
type: "session.idle",
id: "event-question-rejected-idle",
properties: { sessionID: session.id },
});
}, 10);
return;
}
if (
request.method === "POST" &&
request.url?.startsWith("/permission/permission-native-1/reply?")
) {
const chunks = [];
request.on("data", (chunk) => chunks.push(chunk));
request.on("end", async () => {
const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
await writeFile(
join(process.env.XDG_DATA_HOME, "fake-permission-reply.json"),
JSON.stringify({ url: request.url, body }),
);
pendingPermission = null;
emit({
type:
permissionStyle === "legacy"
? "permission.replied"
: "permission.v2.replied",
id: "event-permission-replied",
properties:
permissionStyle === "legacy"
? {
sessionID: session.id,
permissionID: "permission-native-1",
response: body.reply,
}
: {
sessionID: session.id,
requestID: "permission-native-1",
reply: body.reply,
},
});
setTimeout(() => {
json(response, 200, true);
emit({
type: "session.idle",
id: "event-permission-idle",
properties: { sessionID: session.id },
});
}, 10);
});
return;
}
if (
request.method === "POST" &&
request.url === `/session/${session.id}/abort`
)
return json(response, 200, true);
if (
request.method === "POST" &&
request.url === `/session/${session.id}/prompt_async`
) {
const chunks = [];
request.on("data", (chunk) => chunks.push(chunk));
request.on("end", async () => {
const promptPayload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
await appendFile(join(process.env.XDG_DATA_HOME, "fake-prompt-requests.ndjson"), JSON.stringify(promptPayload) + "\n");
if (
promptPayload.providerID !== "openrouter" ||
promptPayload.modelID !== "deepseek/deepseek-v4-flash-0731" ||
"model" in promptPayload
) {
return json(response, 400, {
error: "OpenCode 1.18 prompt model fields must be top-level",
});
}
promptTurnSeq += 1;
json(response, 204, null);
if (lateStragglerRemaining > 0) {
lateStragglerRemaining -= 1;
if (lateStragglerRemaining === 0) {
emit({
type: "message.part.updated",
id: "event-late-straggler-delivery",
properties: {
sessionID: session.id,
part: {
id: "part-late-straggler",
messageID: "message-late-source",
type: "text",
text: "late straggler text must not reach the next turn",
time: { start: 9, end: 10 },
},
},
});
}
}
setTimeout(async () => {
await callFirstPaperclipTool();
const parsedPrompt = parsedPromptText(promptPayload);
if (String(parsedPrompt.message ?? "").includes("native-question")) {
pendingQuestion = nativeQuestion();
emit({
type: "question.asked",
id: "event-question-native-1",
properties: pendingQuestion,
});
emit({
type: "question.asked",
id: "event-question-native-1",
properties: pendingQuestion,
});
return;
}
if (String(parsedPrompt.message ?? "").includes("native-permission")) {
permissionStyle = String(parsedPrompt.message ?? "").includes(
"legacy",
)
? "legacy"
: "v2";
pendingPermission = nativePermission(permissionStyle);
emit({
type:
permissionStyle === "legacy"
? "permission.updated"
: "permission.v2.asked",
id: `event-permission-${permissionStyle}`,
properties: pendingPermission,
});
return;
}
if (String(parsedPrompt.message ?? "").includes("session-aborted")) {
emit({
type: "session.error",
id: "event-session-aborted",
properties: {
sessionID: session.id,
error: {
name: "MessageAbortedError",
data: { message: "Aborted" },
},
},
});
return;
}
if (String(parsedPrompt.message ?? "").includes("session-failed")) {
emit({
type: "session.error",
id: "event-session-failed",
properties: {
sessionID: session.id,
error: {
name: "ProviderError",
message: "The fake provider failed on purpose.",
},
},
});
return;
}
if (
String(parsedPrompt.message ?? "").includes(
"pending-request-then-turn-fails",
)
) {
pendingQuestion = nativeQuestion();
emit({
type: "question.asked",
id: "event-question-pending-then-fail",
properties: pendingQuestion,
});
setTimeout(() => {
emit({
type: "session.error",
id: "event-session-failed-with-pending-request",
properties: {
sessionID: session.id,
error: {
name: "ProviderError",
message: "The fake provider failed with a request still pending.",
},
},
});
}, 10);
return;
}
if (
String(parsedPrompt.message ?? "").includes("late-straggler-source")
) {
emit({
type: "message.updated",
id: "event-late-source-message",
properties: {
sessionID: session.id,
info: {
id: "message-late-source",
sessionID: session.id,
role: "assistant",
},
},
});
await callTerminalTool(promptPayload);
emit({
type: "message.part.updated",
id: "event-late-source-part",
properties: {
sessionID: session.id,
part: {
id: "part-late-source",
messageID: "message-late-source",
type: "text",
text: "done",
time: { start: 1, end: 2 },
},
},
});
emit({
type: "message.updated",
id: "event-late-source-usage",
properties: {
info: {
id: "message-late-source",
sessionID: session.id,
role: "assistant",
tokens: { input: 3, output: 2 },
cost: 0.001,
},
},
});
emit({
type: "session.idle",
id: "event-late-source-idle",
properties: { sessionID: session.id },
});
// Deliver the straggling frame for this message only once a later
// turn's own prompt has already been accepted. Defaults to the
// very next prompt; "-delay-N" postpones it by N prompts.
const delayMatch = String(parsedPrompt.message ?? "").match(
/late-straggler-source-delay-(\d+)/,
);
lateStragglerRemaining = delayMatch ? Number(delayMatch[1]) : 1;
return;
}
const textBeforeFinish = String(parsedPrompt.message ?? "").includes(
"text-before-finish",
);
const correlatedFinal = String(parsedPrompt.message ?? "").includes(
"correlated-final-message",
);
const finalAfterToolCommentary = String(
parsedPrompt.message ?? "",
).includes("final-after-tool-commentary");
const commentaryOnlyBeforeWork = String(
parsedPrompt.message ?? "",
).includes("commentary-only-before-work");
emit({
type: "message.updated",
id: "event-user",
properties: {
sessionID: session.id,
info: { id: "message-user", sessionID: session.id, role: "user" },
},
});
emit({
type: "message.part.updated",
id: "event-user-part",
properties: {
sessionID: session.id,
part: {
id: "part-user",
messageID: "message-user",
type: "text",
text: "submitted prompt must not become assistant text",
},
},
});
emit({
type: "message.updated",
id: "event-assistant",
properties: {
sessionID: session.id,
info: {
id: "message-assistant",
sessionID: session.id,
role: "assistant",
},
},
});
emit({
type: "message.part.updated",
id: "event-patch",
properties: {
sessionID: session.id,
part: {
id: "part-patch",
messageID: "message-assistant",
type: "patch",
files: ["src/index.ts"],
},
},
});
if (commentaryOnlyBeforeWork) {
emit({
type: "message.part.updated",
id: "event-opening-commentary",
properties: {
sessionID: session.id,
part: {
id: "part-opening-commentary",
messageID: "message-assistant",
type: "text",
text: "I will inspect the workspace first.",
time: { start: 1, end: 2 },
},
},
});
emit({
type: "message.part.updated",
id: "event-work-tool",
properties: {
sessionID: session.id,
part: {
id: "part-work-tool",
messageID: "message-assistant",
type: "tool",
tool: "bash",
state: {
status: "completed",
input: { command: "pwd" },
output: "/workspace",
},
},
},
});
await callTerminalTool(promptPayload);
} else if (finalAfterToolCommentary) {
emit({
type: "message.part.updated",
id: "event-tool-commentary",
properties: {
sessionID: session.id,
part: {
id: "part-tool-commentary",
messageID: "message-assistant",
type: "text",
text: "This deliberately long pre-tool commentary discusses possible schema corrections, retries, compatibility details, and several implementation alternatives, but it is not the provider's final response.",
time: { start: 1, end: 2 },
},
},
});
await callTerminalTool(promptPayload);
emit({
type: "message.part.updated",
id: "event-tool-part",
properties: {
sessionID: session.id,
part: {
id: "part-tool",
messageID: "message-assistant",
type: "tool",
tool: "paperclip_paperclip_finish",
state: { status: "completed", output: "accepted" },
},
},
});
emit({
type: "message.updated",
id: "event-post-tool-final-message",
properties: {
sessionID: session.id,
info: {
id: "message-post-tool-final",
sessionID: session.id,
role: "assistant",
},
},
});
emit({
type: "message.part.updated",
id: "event-post-tool-final",
properties: {
sessionID: session.id,
part: {
id: "part-post-tool-final",
messageID: "message-post-tool-final",
type: "text",
text: "This is the complete substantive answer emitted after the accepted completion tool call.",
time: { start: 3, end: 4 },
},
},
});
} else if (correlatedFinal) {
emit({
type: "message.part.updated",
id: "event-commentary",
properties: {
sessionID: session.id,
part: {
id: "part-commentary",
messageID: "message-assistant",
type: "text",
text: "I will write the result now.",
time: { start: 1, end: 2 },
},
},
});
await callTerminalTool(promptPayload);
emit({
type: "message.updated",
id: "event-final-message",
properties: {
sessionID: session.id,
info: {
id: "message-final",
sessionID: session.id,
role: "assistant",
},
},
});
emit({
type: "message.part.updated",
id: "event-finish-part",
properties: {
sessionID: session.id,
part: {
id: "part-finish",
messageID: "message-final",
type: "tool",
tool: "paperclip_paperclip_finish",
state: { status: "completed", output: "accepted" },
},
},
});
emit({
type: "message.part.updated",
id: "event-correlated-answer",
properties: {
sessionID: session.id,
part: {
id: "part-correlated-answer",
messageID: "message-final",
type: "text",
text: "Correlated substantive final answer.",
time: { start: 3, end: 4 },
},
},
});
emit({
type: "message.part.updated",
id: "event-duplicate-finish",
properties: {
sessionID: session.id,
part: {
id: "part-duplicate-finish",
messageID: "message-final",
type: "tool",
tool: "unknown",
callID: "functions.paperclip_paperclip_finish:24",
state: {
status: "error",
error: "Tool execution aborted",
metadata: { interrupted: true },
},
},
},
});
emit({
type: "message.updated",
id: "event-trailing-message",
properties: {
sessionID: session.id,
info: {
id: "message-trailing",
sessionID: session.id,
role: "assistant",
},
},
});
emit({
type: "message.part.updated",
id: "event-trailing-ack",
properties: {
sessionID: session.id,
part: {
id: "part-trailing-ack",
messageID: "message-trailing",
type: "text",
text: "Done.",
time: { start: 5, end: 6 },
},
},
});
} else if (textBeforeFinish) {
emit({
type: "message.part.updated",
id: "event-answer",
properties: {
sessionID: session.id,
part: {
id: "part-answer",
messageID: "message-assistant",
type: "text",
text: "Substantive answer before finish.",
time: { start: 1, end: 2 },
},
},
});
await callTerminalTool(promptPayload);
emit({
type: "message.part.updated",
id: "event-ack",
properties: {
sessionID: session.id,
part: {
id: "part-ack",
messageID: "message-assistant",
type: "text",
text: "Done.",
time: { start: 3, end: 4 },
},
},
});
} else {
await callTerminalTool(promptPayload);
emit({
type: "message.part.updated",
id: "event-1",
properties: {
sessionID: session.id,
part: {
id: "part-1",
messageID: "message-assistant",
type: "text",
text: "done [guide](guide.md)",
time: { start: 1, end: 2 },
},
},
});
emit({
type: "message.part.updated",
id: "event-1",
properties: {
sessionID: session.id,
part: {
id: "part-1",
messageID: "message-assistant",
type: "text",
text: "done",
},
},
});
}
emit({
type: "message.updated",
id: "event-2",
properties: {
info: {
id: "message-assistant",
sessionID: session.id,
role: "assistant",
tokens: { input: 3, output: 2 },
cost: 0.001,
},
},
});
emit({
type: "session.idle",
id: "event-3",
properties: { sessionID: session.id },
});
}, 100);
});
return;
}
json(response, 404, { error: "not found" });
});
server.listen(port, "127.0.0.1");
for (const signal of ["SIGTERM", "SIGINT"])
process.on(signal, () => server.close(() => process.exit(0)));