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
140 changes: 133 additions & 7 deletions linear-slack/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,40 @@
* model navigates `./linear` on demand (see persona.systemPrompt) instead of
* having the whole board pre-loaded into context.
*
* The handler is thin: it gates the event, reconstructs the thread's
* conversation history from memory (multi-turn), hands the turn to the harness,
* posts the harness's reply to Slack, and records the turn. DMs are not handled.
* The handler gates the event, reconstructs the thread's conversation history
* from memory (multi-turn), hands the turn to the harness, then posts the reply.
* DMs are not handled.
*
* Board WRITES do NOT go through the harness's filesystem. The mounted `./linear`
* tree is for READS only — the harness once "created" an issue by hand-writing a
* JSON file into it (inventing an `AR-NN` ref + UUID), which is not a Linear
* mutation and silently created nothing while the reply claimed success. Instead
* the harness resolves ids from the VFS and emits a fenced `linear-actions`
* block; this handler executes those actions through `linearClient()` (the real
* writeback: draft → `issueCreate` → receipt) and reports the CONFIRMED Linear
* url. Unconfirmed writes are surfaced, never claimed as done.
*/
import {
defineAgent,
type WorkforceCtx,
type WorkforceProviderEvent,
} from '@agentworkforce/runtime';
import { slackClient } from '@relayfile/relay-helpers';
import { linearClient, slackClient } from '@relayfile/relay-helpers';

const MEMORY_TAG = 'linear-slack';
const HISTORY_LIMIT = 8;

// The harness appends board mutations as a single fenced block of JSON actions
// (an array, or one object). Reads stay in the VFS; only writes ride this rail.
const LINEAR_ACTION_FENCE = /```linear-actions\s*\n([\s\S]*?)```/;
// Allow-listed Linear IssueCreateInput fields. Whitelisting (vs forwarding the
// raw object) keeps a stray read-only field — `id`/`identifier` — from tripping
// the adapter's `rejectReadOnlyFields` and failing the whole create.
const CREATE_ISSUE_FIELDS = [
'teamId', 'title', 'description', 'projectId', 'priority',
'assigneeId', 'stateId', 'labelIds', 'dueDate', 'estimate', 'parentId', 'cycleId',
] as const;

interface SlackMessage {
channel: string;
ts: string;
Expand All @@ -36,6 +56,25 @@ interface SlackClientLike {
reply(channel: string, threadTs: string, text: string): Promise<{ channel: string; ts: string }>;
}

/**
* The slice of `linearClient()` this handler uses for writes. Both calls go
* through the VFS writeback (draft → mutation → receipt) and return
* `{ id, url }` — but they FALL BACK to the draft path when no receipt arrives
* (relay-helpers `created()` swallows the timeout), so a `url` that isn't an
* http(s) link means "Linear never confirmed it." Callers must check.
*/
interface LinearWriteClient {
createIssue(
args: { teamId: string; title: string } & Record<string, unknown>,
): Promise<{ id: string; url: string }>;
comment(issueId: string, body: string): Promise<{ id: string; url: string }>;
}

interface LinearAction {
action: string;
[key: string]: unknown;
}

export default defineAgent({
triggers: {
// Channel-scoped watch paths are the wake gate: the cloud dispatcher
Expand All @@ -55,14 +94,15 @@ export default defineAgent({
],
},
handler: async (ctx, event) => {
await handleSlackEvent(ctx, event, slackClient());
await handleSlackEvent(ctx, event, slackClient(), linearClient());
},
});

export async function handleSlackEvent(
ctx: WorkforceCtx,
event: WorkforceProviderEvent,
slack: SlackClientLike,
linear: LinearWriteClient,
): Promise<void> {
if (event.source !== 'slack') {
logSkip(ctx, event, 'non-slack event source');
Expand Down Expand Up @@ -130,9 +170,95 @@ export async function handleSlackEvent(
return;
}

await postReply(slack, msg, reply);
// Split any board-mutation block off the prose and run it through the real
// Linear writeback. The user-facing reply is the harness prose plus the
// CONFIRMED outcome of each action — never the harness's own success claim.
const { prose, actions, malformed } = extractActions(reply);
const outcomes = malformed
? ['⚠️ I tried to update the board but my action block was malformed — nothing was changed.']
: await executeLinearActions(ctx, linear, actions);

const finalReply = [prose, ...outcomes].map((s) => s.trim()).filter(Boolean).join('\n\n')
|| "I looked but don't have anything to add on that.";

await postReply(slack, msg, finalReply);
await rememberTurn(ctx, convKey, 'user', text);
await rememberTurn(ctx, convKey, 'assistant', reply);
await rememberTurn(ctx, convKey, 'assistant', finalReply);
}

/** Pull the fenced `linear-actions` block out of the reply, leaving the prose. */
function extractActions(reply: string): { prose: string; actions: LinearAction[]; malformed: boolean } {
const match = reply.match(LINEAR_ACTION_FENCE);
if (!match) return { prose: reply, actions: [], malformed: false };
const prose = reply.replace(LINEAR_ACTION_FENCE, '').trim();
try {
const parsed = JSON.parse(match[1].trim());
const actions = Array.isArray(parsed) ? parsed : [parsed];
// Drop anything that isn't a tagged action object.
const valid = actions.filter(
(a): a is LinearAction => Boolean(a) && typeof a === 'object' && typeof a.action === 'string',
);
return { prose, actions: valid, malformed: false };
Comment on lines +198 to +201

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Currently, any invalid action objects (e.g., missing the action property, or non-object elements like null) are silently filtered out and ignored. This can lead to partial execution of a multi-action block without any warning or error being surfaced to the user.

To prevent silent failures, we should validate that every element in the parsed actions array is a valid LinearAction. If any element is invalid, we should treat the entire block as malformed so that the user/LLM is alerted and no partial state is committed.

    const allValid = actions.every(
      (a): a is LinearAction => Boolean(a) && typeof a === 'object' && typeof a.action === 'string',
    );
    if (!allValid) {
      return { prose, actions: [], malformed: true };
    }
    return { prose, actions, malformed: false };

} catch {
return { prose, actions: [], malformed: true };
}
}

/**
* Execute board mutations and return one CONFIRMED-or-flagged line each. A write
* counts as done only when Linear hands back a real http(s) url; the draft-path
* fallback (no receipt) is reported as unconfirmed so we never fabricate success.
*/
async function executeLinearActions(
ctx: WorkforceCtx,
linear: LinearWriteClient,
actions: LinearAction[],
): Promise<string[]> {
const outcomes: string[] = [];
for (const action of actions) {
try {
if (action.action === 'create_issue') {
const teamId = str(action.teamId);
const title = str(action.title);
if (!teamId || !title) {
outcomes.push(`⚠️ Couldn't create the issue — missing \`${!teamId ? 'teamId' : 'title'}\`. Can you confirm it?`);
continue;
}
const { url } = await linear.createIssue({ ...pick(action, CREATE_ISSUE_FIELDS), teamId, title });
outcomes.push(confirm(ctx, 'create_issue', url, `✅ Created the issue: ${url}`));
} else if (action.action === 'comment') {
const missing = !str(action.issueId) ? 'issueId' : !str(action.body) ? 'body' : null;
if (missing) {
outcomes.push(`⚠️ Couldn't add the comment — missing \`${missing}\`.`);
continue;
}
const { url } = await linear.comment(String(action.issueId), String(action.body));
outcomes.push(confirm(ctx, 'comment', url, `✅ Added the comment: ${url}`));
} else {
outcomes.push(`⚠️ I can't do "${action.action}" yet — only creating issues and commenting.`);
}
} catch (err) {
ctx.log?.('error', 'linear-slack.action.failed', { action: action.action, error: errorMessage(err) });
outcomes.push(`⚠️ "${action.action}" failed: ${errorMessage(err)}`);
}
}
return outcomes;
}

/** A receipt url proves the mutation landed; the draft-path fallback does not. */
function confirm(ctx: WorkforceCtx, action: string, url: string, okMessage: string): string {
if (/^https?:\/\//i.test(url)) return okMessage;
ctx.log?.('error', 'linear-slack.action.unconfirmed', { action, url });
return '⚠️ I submitted that to Linear but it never confirmed (no writeback receipt) — please double-check the board before relying on it.';
}

/** Copy only the allow-listed keys whose values are present. */
function pick(source: LinearAction, keys: readonly string[]): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const key of keys) {
if (source[key] !== undefined && source[key] !== null) out[key] = source[key];
}
return out;
}

/**
Expand Down
36 changes: 27 additions & 9 deletions linear-slack/persona.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,8 @@ export default definePersona({
systemPrompt: [
'You are a Linear board assistant chatting with a teammate in a Slack channel.',
'',
'The team Linear data is mounted READ/WRITE. `./linear/LAYOUT.md` is the self-describing',
'map of the mount — skim it if unsure. Everything you need for issues is under',
'The team Linear data is mounted at `./linear` for READING. `./linear/LAYOUT.md` is the',
'self-describing map of the mount — skim it if unsure. Everything you need for issues is under',
'`./linear/issues/`:',
'- `_index.json` — the full index of every issue. START HERE.',
'- flat issue files named `<REF>__<uuid>.json` (e.g. `AR-10__<uuid>.json`).',
Expand All @@ -88,19 +88,37 @@ export default definePersona({
'sample or just the most recent. Never invent an issue without reading its file. Read only',
'what the question needs, but "all open issues" means all of them.',
'',
'To ORGANIZE the board, only when the teammate clearly asks for a change. The mount',
'writes back to Linear; check the resource `.schema.json` for writable fields first',
'(fields marked `readOnly` are server-managed). Edit the canonical issue file (e.g.',
'`./linear/issues/by-id/<ID>.json`) to change state/assignee/title; add a comment by',
'writing a new JSON file under that issue’s `comments/` directory.',
'Make the smallest change asked for. If they are only discussing, propose a plan and do not edit.',
'To CHANGE the board (ONLY when the teammate clearly asks for a change), do NOT edit or',
'create files under `./linear` — writes there are discarded and change nothing in Linear.',
'Instead, resolve the ids you need by reading the VFS, then put the mutations in ONE fenced',
'block at the very END of your reply, exactly like this:',
'',
'```linear-actions',
'[',
' { "action": "create_issue", "teamId": "<team uuid>", "title": "…", "description": "…", "projectId": "<project uuid, optional>", "priority": 0 },',
' { "action": "comment", "issueId": "<issue uuid>", "body": "…" }',
']',
'```',
'',
'Action rules:',
'- `create_issue` needs `teamId` and `title` (optional: `description`, `projectId`, `priority`',
' 0–4, `assigneeId`, `stateId`). `comment` needs `issueId` (the issue UUID) and `body`.',
'- Resolve REAL ids from the VFS: team from `./linear/teams/`, project from `./linear/projects/`,',
' an issue’s UUID from its file’s `objectId`. NEVER invent an id, a ref like `AR-83`, or a UUID.',
' If a required id is missing, ASK instead of guessing.',
'- A milestone CANNOT be set on create — if asked, create the issue in the project and say the',
' milestone still has to be set in Linear.',
'- Do NOT announce success in your prose (no “Created AR-83”). Say what you are about to do',
' (“Creating that issue in Launch SDK…”); the system runs each action and appends the CONFIRMED',
' Linear link, or a warning if it failed. Make the smallest change asked for.',
'- If they are only discussing, propose a plan and emit NO action block.',
'',
'Reply with concise, Slack-friendly plain text (Linear refs like ENG-12 are welcome).',
'Do NOT post to Slack yourself — your final stdout is sent back as the reply.',
].join('\n'),
harnessSettings: {
reasoning: 'medium',
// The harness has to boot, mount, navigate, and (sometimes) write — give it room.
// The harness has to boot, mount, and navigate the board on demand — give it room.
timeoutSeconds: 300,
},

Expand Down
152 changes: 152 additions & 0 deletions tests/linear-slack-agent.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import { handleSlackEvent } from '../.test-build/linear-slack/agent.js';

function ctx(overrides = {}) {
const logs = [];
const memorySaves = [];
return {
logs,
memorySaves,
persona: { id: 'linear-slack', inputs: {}, inputSpecs: {} },
sandbox: { cwd: '/home/daytona/workspace' },
memory: {
recall: async () => [],
save: async (content, opts) => { memorySaves.push({ content, opts }); return { id: 'm1' }; },
},
harness: { run: async () => ({ output: overrides.harnessOutput ?? '' }) },
log: (level, message, attrs) => logs.push({ level, message, attrs }),
...overrides,
};
}

function slackSpy() {
const posts = [];
return {
posts,
async post(channel, text) { posts.push({ channel, text }); return { channel, ts: 'ts-1' }; },
async reply(channel, threadTs, text) { posts.push({ channel, threadTs, text }); return { channel, ts: 'ts-1' }; },
};
}

function linearSpy(overrides = {}) {
const created = [];
const comments = [];
return {
created,
comments,
async createIssue(args) {
created.push(args);
return overrides.createIssue ?? { id: 'AR-84', url: 'https://linear.app/agentrelay/issue/AR-84/remove-dashboard' };
},
async comment(issueId, body) {
comments.push({ issueId, body });
return overrides.comment ?? { id: 'c1', url: 'https://linear.app/agentrelay/issue/AR-10#comment-c1' };
},
};
}

function slackEvent(text) {
return {
source: 'slack',
id: 'evt-1',
type: 'message.created',
payload: { channel: 'C0B9287EP6Y', ts: '1781004465.912899', text, user: 'U1', is_bot: false },
};
}

const ACTION = (obj) => '```linear-actions\n' + JSON.stringify(obj) + '\n```';

test('create_issue action runs through linearClient and reports the CONFIRMED url', async () => {
const runtime = ctx({
harnessOutput: `Creating that issue in Launch SDK now.\n\n${ACTION([
{ action: 'create_issue', teamId: 'team-uuid', title: 'Remove the dashboard from the agent-relay up command', projectId: 'proj-uuid', description: 'desc' },
])}`,
});
const slack = slackSpy();
const linear = linearSpy();

await handleSlackEvent(runtime, slackEvent('make an issue to remove the dashboard'), slack, linear);

// the real Linear writeback was invoked with allow-listed fields + required ids
assert.equal(linear.created.length, 1);
assert.deepEqual(linear.created[0], {
teamId: 'team-uuid', title: 'Remove the dashboard from the agent-relay up command',
description: 'desc', projectId: 'proj-uuid',
});
// the reply carries the prose AND the confirmed link, and the action block is gone
const posted = slack.posts.at(-1).text;
assert.match(posted, /Creating that issue in Launch SDK now\./);
assert.match(posted, /✅ Created the issue: https:\/\/linear\.app\/agentrelay\/issue\/AR-84/);
assert.doesNotMatch(posted, /linear-actions/);
// the recorded turn is the confirmed reply, not the harness's raw output
assert.ok(runtime.memorySaves.some((s) => /✅ Created the issue/.test(s.content)));
});

test('an unconfirmed create (no receipt → draft-path fallback) is flagged, never claimed done', async () => {
const runtime = ctx({
harnessOutput: `On it.\n\n${ACTION([{ action: 'create_issue', teamId: 't', title: 'x' }])}`,
});
const slack = slackSpy();
// url falls back to the draft path when the writeback worker never acks
const linear = linearSpy({ createIssue: { id: '/linear/issues/issues abc.json', url: '/linear/issues/issues abc.json' } });

await handleSlackEvent(runtime, slackEvent('make an issue'), slack, linear);

const posted = slack.posts.at(-1).text;
assert.doesNotMatch(posted, /✅/);
assert.match(posted, /never confirmed|double-check/i);
assert.ok(runtime.logs.some((l) => l.message === 'linear-slack.action.unconfirmed'));
});

test('create_issue missing teamId is refused without calling Linear', async () => {
const runtime = ctx({
harnessOutput: `${ACTION([{ action: 'create_issue', title: 'no team' }])}`,
});
const slack = slackSpy();
const linear = linearSpy();

await handleSlackEvent(runtime, slackEvent('make an issue'), slack, linear);

assert.equal(linear.created.length, 0);
assert.match(slack.posts.at(-1).text, /missing `teamId`/);
});

test('comment action posts through linearClient and confirms', async () => {
const runtime = ctx({
harnessOutput: ACTION([{ action: 'comment', issueId: 'issue-uuid', body: 'looks good' }]),
});
const slack = slackSpy();
const linear = linearSpy();

await handleSlackEvent(runtime, slackEvent('comment on AR-10'), slack, linear);

assert.deepEqual(linear.comments, [{ issueId: 'issue-uuid', body: 'looks good' }]);
assert.match(slack.posts.at(-1).text, /✅ Added the comment/);
});

test('a read-only / discussion turn posts prose and triggers NO writes', async () => {
const runtime = ctx({ harnessOutput: 'There are 3 open issues in Launch SDK: AR-10, AR-11, AR-17.' });
const slack = slackSpy();
const linear = linearSpy();

await handleSlackEvent(runtime, slackEvent('what is open in launch sdk?'), slack, linear);

assert.equal(linear.created.length, 0);
assert.equal(linear.comments.length, 0);
assert.equal(slack.posts.at(-1).text, 'There are 3 open issues in Launch SDK: AR-10, AR-11, AR-17.');
});

test('a malformed action block changes nothing and says so', async () => {
const runtime = ctx({
harnessOutput: 'Trying.\n\n```linear-actions\n{ not valid json,, }\n```',
});
const slack = slackSpy();
const linear = linearSpy();

await handleSlackEvent(runtime, slackEvent('make an issue'), slack, linear);

assert.equal(linear.created.length, 0);
assert.match(slack.posts.at(-1).text, /malformed/);
});