Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/sense/social/drafts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import path from "node:path";
import { afterEach, beforeEach, describe, test } from "node:test";
import {
approveSocialDraft,
cancelSocialDraft,
canonicalJson,
claimApprovedSocialDraft,
createSocialDraft,
Expand Down Expand Up @@ -113,5 +114,14 @@ describe("social drafts", () => {
claimApprovedSocialDraft(draft.id, digest, 3100),
/approval expired/,
);
assert.equal((await listSocialDrafts())[0]?.state, "expired");
});

test("cancelling clears a pending approval", async () => {
const draft = await createSocialDraft(input(), 1000);
await requestSocialDraftApproval(draft.id, 1, 2000);
const cancelled = await cancelSocialDraft(draft.id, 3000);
assert.equal(cancelled.state, "cancelled");
assert.equal(cancelled.approval, undefined);
});
});
25 changes: 23 additions & 2 deletions src/sense/social/drafts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,15 +293,16 @@ export async function claimApprovedSocialDraft(
expectedDigest: string,
now: number = Date.now(),
): Promise<SocialDraft> {
return mutate((store) => {
const result = await mutate((store) => {
const draft = requireDraft(store, id);
if (draft.state !== "approved" || !draft.approval) {
throw new Error(`social draft in state "${draft.state}" is not publishable`);
}
if (Date.parse(draft.approval.expiresAt) <= now) {
draft.state = "expired";
draft.updatedAt = new Date(now).toISOString();
throw new Error("social draft approval expired");
draft.events.push({ at: draft.updatedAt, kind: "outcome", detail: "approval-expired" });
return { expired: true as const };
}
const actual = socialDraftDigest(draft);
if (actual !== expectedDigest || draft.approval.digest !== expectedDigest) {
Expand All @@ -312,6 +313,26 @@ export async function claimApprovedSocialDraft(
draft.approval.claimedAt = at;
draft.updatedAt = at;
draft.events.push({ at, kind: "claimed", detail: actual.slice(0, 12) });
return { expired: false as const, draft: clone(draft) };
});
if (result.expired) throw new Error("social draft approval expired");
return result.draft;
}

export async function cancelSocialDraft(
id: string,
now: number = Date.now(),
): Promise<SocialDraft> {
return mutate((store) => {
const draft = requireDraft(store, id);
if (["publishing", "published", "partial"].includes(draft.state)) {
throw new Error(`social draft in state "${draft.state}" cannot be cancelled`);
}
const at = new Date(now).toISOString();
draft.state = "cancelled";
draft.approval = undefined;
draft.updatedAt = at;
draft.events.push({ at, kind: "cancelled" });
return clone(draft);
});
}
68 changes: 68 additions & 0 deletions src/sense/social/tool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, test } from "node:test";
import { socialComposeTool } from "./tool.js";

let home: string;
let previousHome: string | undefined;

beforeEach(() => {
previousHome = process.env.LISA_HOME;
home = fs.mkdtempSync(path.join(os.tmpdir(), "lisa-social-tool-"));
process.env.LISA_HOME = home;
});

afterEach(() => {
if (previousHome === undefined) delete process.env.LISA_HOME;
else process.env.LISA_HOME = previousHome;
fs.rmSync(home, { recursive: true, force: true });
});

async function call(input: unknown): Promise<unknown> {
const text = await socialComposeTool.execute(
input as never,
{} as never,
);
return JSON.parse(text);
}

describe("social_compose tool", () => {
test("creates, updates, and requests confirmation without a publish action", async () => {
const created = (await call({
action: "new_draft",
draft: {
targets: [
{
connectorId: "bluesky-official",
accountId: "alice",
platform: "bluesky",
},
],
canonical: { text: "hello", media: [] },
},
})) as { id: string; revision: number };
assert.equal(created.revision, 1);

const updated = (await call({
action: "update_draft",
id: created.id,
expectedRevision: 1,
patch: { canonical: { text: "hello world", media: [] } },
})) as { revision: number };
assert.equal(updated.revision, 2);

const preview = (await call({
action: "request_confirmation",
id: created.id,
expectedRevision: 2,
})) as { approvalDigest: string; instruction: string };
assert.equal(preview.approvalDigest.length, 64);
assert.match(preview.instruction, /Publishing remains blocked/);
assert.doesNotMatch(
JSON.stringify(socialComposeTool.inputSchema),
/"publish"/,
);
});
});
148 changes: 148 additions & 0 deletions src/sense/social/tool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import type { ToolDefinition } from "../../types.js";
import {
createSocialDraft,
getSocialDraft,
listSocialDrafts,
requestSocialDraftApproval,
updateSocialDraft,
} from "./drafts.js";
import { discoverSocialConnectors } from "./manifest.js";
import type {
NewSocialDraft,
SocialDraftPatch,
} from "./types.js";

interface SocialComposeInput {
action:
| "connectors"
| "drafts"
| "view"
| "new_draft"
| "update_draft"
| "request_confirmation";
id?: string;
expectedRevision?: number;
draft?: NewSocialDraft;
patch?: SocialDraftPatch;
}

/**
* Model-visible social surface. Deliberately no approve/publish actions:
* confirmation is a local UI/CLI action and publishing is a deterministic host
* runner, never a capability handed to the model.
*/
export const socialComposeTool: ToolDefinition<SocialComposeInput, string> = {
name: "social_compose",
description:
"Prepare social-media posts through LISA's host-enforced draft workflow. " +
"Can list connectors/drafts, create or update a draft, inspect it, and request a confirmation preview. " +
"It CANNOT approve or publish: the user must confirm the immutable preview in a trusted local UI.",
annotations: {
title: "Social post composer",
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
inputSchema: {
type: "object",
properties: {
action: {
type: "string",
enum: [
"connectors",
"drafts",
"view",
"new_draft",
"update_draft",
"request_confirmation",
],
},
id: { type: "string" },
expectedRevision: { type: "number" },
draft: { type: "object" },
patch: { type: "object" },
},
required: ["action"],
additionalProperties: false,
},
async execute(input) {
switch (input.action) {
case "connectors": {
const connectors = await discoverSocialConnectors();
return JSON.stringify(
connectors.map((item) =>
item.manifest
? {
id: item.manifest.id,
displayName: item.manifest.displayName,
platform: item.manifest.platform,
skill: item.manifest.skill,
available: true,
}
: {
plugin: item.plugin,
available: false,
error: item.error,
},
),
);
}
case "drafts": {
const drafts = await listSocialDrafts();
return JSON.stringify(
drafts.map(({ id, revision, state, targets, updatedAt }) => ({
id,
revision,
state,
targets,
updatedAt,
})),
);
}
case "view": {
if (!input.id) throw new Error("social_compose view requires id");
const draft = await getSocialDraft(input.id);
if (!draft) throw new Error(`social draft "${input.id}" not found`);
return JSON.stringify(draft);
}
case "new_draft": {
if (!input.draft) {
throw new Error("social_compose new_draft requires draft");
}
return JSON.stringify(await createSocialDraft(input.draft));
}
case "update_draft": {
if (!input.id || input.expectedRevision == null || !input.patch) {
throw new Error(
"social_compose update_draft requires id, expectedRevision, and patch",
);
}
return JSON.stringify(
await updateSocialDraft(
input.id,
input.expectedRevision,
input.patch,
),
);
}
case "request_confirmation": {
if (!input.id || input.expectedRevision == null) {
throw new Error(
"social_compose request_confirmation requires id and expectedRevision",
);
}
const result = await requestSocialDraftApproval(
input.id,
input.expectedRevision,
);
return JSON.stringify({
draft: result.draft,
approvalDigest: result.digest,
instruction:
"Show this exact preview to the user. Publishing remains blocked until they approve it in the trusted local Sense UI.",
});
}
}
},
};
5 changes: 5 additions & 0 deletions src/tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { webSearchTool } from "./web_search.js";
import { takoapiTool } from "./takoapi.js";
import { writeTool } from "./write.js";
import { kbTools, restrictKbIngestToWatchlist } from "../kb/tool.js";
import { socialComposeTool } from "../sense/social/tool.js";

export interface ToolRegistryOptions {
includeVoice?: boolean;
Expand Down Expand Up @@ -102,6 +103,7 @@ export function buildToolRegistry(opts: ToolRegistryOptions = {}): ToolDefinitio
githubTool as ToolDefinition,
npmInfoTool as ToolDefinition,
mcpTool as ToolDefinition,
socialComposeTool as ToolDefinition,
signalAgentTool as ToolDefinition,
agentRecapTool as ToolDefinition,
// Personal knowledge base (docs/PLAN_KNOWLEDGE_BASE_v1.0.md):
Expand Down Expand Up @@ -171,6 +173,9 @@ export const AUTONOMOUS_BLOCKED_TOOL_NAMES = new Set([
"run_checks",
"github",
"mcp",
// Social drafts must originate in an attended user conversation. Publishing
// is never model-visible, but unattended draft spam is still undesirable.
"social_compose",
// takoapi calls spend the user's TAKO_KEY and send data to a remote agent —
// not something an unattended/remote-origin run should do on its own.
"takoapi",
Expand Down
Loading