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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ published version with a date and open a fresh empty `[Unreleased]` above it.

### Added

- `@relayfile/adapter-linear` now owns cursor-safe AgentActivity reconciliation so consumers can prevent duplicate terminal activities after ambiguous provider writes.
- `@relayfile/adapter-github/webhook-identity` now exports fail-closed check-run pull-request identity parsing for API and HTML URLs, including repository ownership validation for webhook consumers.
- `@relayfile/adapter-gitlab` now creates issues, branches, and merge requests from file-native drafts; accepts or closes/reopens merge requests through canonical sidecars; and publishes schemas, examples, catalog paths, and `LAYOUT.md` guidance for every supported GitLab writeback route.
- `@relayfile/adapter-github` now declares and routes pull-request `ready_for_review`, `labeled`, and `unlabeled` webhooks so review flows can wake on draft and policy-label transitions.
Expand Down
250 changes: 250 additions & 0 deletions packages/linear/src/agent-activity-reconciliation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { reconcileLinearAgentActivity } from './agent-activity-reconciliation.js';

describe('Linear AgentActivity reconciliation', () => {
it('owns cursor pagination and finds exact frozen content', async () => {
const cursors: unknown[] = [];
const result = await reconcileLinearAgentActivity({
sessionId: 'session-1',
activity: { type: 'response', body: 'Ready for review.' },
createdAtOrAfter: '2026-09-28T10:00:00.000Z',
execute: async (request) => {
const variables = request.body.variables as {
sessionId: string;
after: string | null;
};
cursors.push(variables.after);
assert.equal(request.action, 'list_agent_activities');
assert.match(String(request.body.query), /RelayfileAgentSessionActivities/);
if (variables.after === null) {
return {
ok: true,
status: 200,
data: {
data: {
agentSession: {
activities: {
nodes: [
{
id: 'prompt-1',
createdAt: '2026-09-28T09:58:00.000Z',
content: {
__typename: 'AgentActivityPromptContent',
body: 'Please keep going.',
},
},
{
id: 'old-identical-activity',
createdAt: '2026-09-28T09:59:00.000Z',
content: {
__typename: 'AgentActivityResponseContent',
body: 'Ready for review.',
},
},
],
pageInfo: { hasNextPage: true, endCursor: 'page-2' },
},
},
},
},
};
}
return {
ok: true,
status: 200,
data: {
data: {
agentSession: {
activities: {
nodes: [{
id: 'activity-1',
createdAt: '2026-09-28T10:00:01.000Z',
content: {
__typename: 'AgentActivityResponseContent',
body: 'Ready for review.',
},
}],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
},
},
};
},
});

assert.deepEqual(cursors, [null, 'page-2']);
assert.deepEqual(result, {
found: true,
externalId: 'activity-1',
status: 200,
});
});

it('fails closed on provider errors and repeated cursors', async () => {
await assert.rejects(
reconcileLinearAgentActivity({
sessionId: 'session-1',
activity: { type: 'error', body: 'Failed.' },
createdAtOrAfter: '2026-09-28T10:00:00.000Z',
execute: async () => ({
ok: true,
status: 200,
data: { errors: [{ message: 'permission denied' }] },
}),
}),
/permission denied/,
);

await assert.rejects(
reconcileLinearAgentActivity({
sessionId: 'session-1',
activity: { type: 'error', body: 'Failed.' },
createdAtOrAfter: '2026-09-28T10:00:00.000Z',
execute: async () => ({
ok: true,
status: 200,
data: {
data: {
agentSession: {
activities: {
nodes: [],
pageInfo: { hasNextPage: true, endCursor: 'same' },
},
},
},
},
}),
}),
/invalid cursor/,
);
});

it('fails closed when successful history responses are incomplete', async () => {
const malformedPayloads = [
{ data: { agentSession: null } },
{ data: { agentSession: {} } },
{ data: { agentSession: { activities: { pageInfo: {
hasNextPage: false,
} } } } },
{ data: { agentSession: { activities: {
nodes: [],
pageInfo: {},
} } } },
];

for (const data of malformedPayloads) {
await assert.rejects(
reconcileLinearAgentActivity({
sessionId: 'session-1',
activity: { type: 'response', body: 'Ready for review.' },
createdAtOrAfter: '2026-09-28T10:00:00.000Z',
execute: async () => ({ ok: true, status: 200, data }),
}),
/malformed history/,
);
}
});

it('rejects malformed GraphQL error envelopes before trusting history', async () => {
for (const errors of [null, 'upstream failed', { message: 'failed' }]) {
await assert.rejects(
reconcileLinearAgentActivity({
sessionId: 'session-1',
activity: { type: 'response', body: 'Ready for review.' },
createdAtOrAfter: '2026-09-28T10:00:00.000Z',
execute: async () => ({
ok: true,
status: 200,
data: {
errors,
data: {
agentSession: {
activities: {
nodes: [],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
},
},
}),
}),
/GraphQL errors is not an array/,
);
}
});

it('requires a stable provider id before confirming a content match', async () => {
await assert.rejects(
reconcileLinearAgentActivity({
sessionId: 'session-1',
activity: { type: 'response', body: 'Ready for review.' },
createdAtOrAfter: '2026-09-28T10:00:00.000Z',
execute: async () => ({
ok: true,
status: 200,
data: {
data: {
agentSession: {
activities: {
nodes: [{
createdAt: '2026-09-28T10:00:01.000Z',
content: {
__typename: 'AgentActivityResponseContent',
body: 'Ready for review.',
},
}],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
},
},
}),
}),
/missing an id, createdAt, or content/,
);
});

it('rejects incomplete activity content before reporting absence', async () => {
const malformedContent = [
{},
{ __typename: 'AgentActivityFutureContent' },
{ __typename: 'AgentActivityResponseContent' },
{
__typename: 'AgentActivityActionContent',
action: 'deploy',
parameter: null,
},
];

for (const content of malformedContent) {
await assert.rejects(
reconcileLinearAgentActivity({
sessionId: 'session-1',
activity: { type: 'response', body: 'Ready for review.' },
createdAtOrAfter: '2026-09-28T10:00:00.000Z',
execute: async () => ({
ok: true,
status: 200,
data: {
data: {
agentSession: {
activities: {
nodes: [{
id: 'activity-1',
createdAt: '2026-09-28T10:00:01.000Z',
content,
}],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
},
},
}),
}),
/malformed history/,
);
}
});
});
Loading
Loading