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
3 changes: 2 additions & 1 deletion packages/persona-kit/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@agentworkforce/persona-kit",
"version": "3.0.40",
"version": "3.0.41",
"private": false,
"type": "module",
"main": "dist/index.js",
Expand Down Expand Up @@ -38,6 +38,7 @@
},
"dependencies": {
"@relayfile/adapter-core": "^0.3.26",
"@relayfile/adapter-linear": "^0.3.8",
"@relayfile/local-mount": "^0.7.24"
},
"devDependencies": {
Expand Down
7 changes: 6 additions & 1 deletion packages/persona-kit/src/define.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,12 @@ test('TypedTriggerMap gives per-provider event autocomplete; arbitrary providers
{ on: 'pull_request.opened' },
{ on: 'off_registry.github_event' }
],
linear: [{ on: 'issue.create' }],
linear: [
{ on: 'issue.create' },
{ on: 'AgentSessionEvent.created' },
{ on: 'AgentSessionEvent.prompted' },
{ on: 'AppUserNotification.issueCommentMention' }
],
slack: [{ on: 'message.created' }],
customProvider: [{ on: 'custom.event' }]
};
Expand Down
3 changes: 2 additions & 1 deletion packages/persona-kit/src/define.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ import type {
import type { KnownProviderName, KnownTriggerName } from './triggers.js';
import type { ScopeKey, ScopeKeyProvider } from './scope-keys.js';
import type { KnownPersonaTag } from './constants.js';
import type { LinearAgentWebhookEvent } from '@relayfile/adapter-linear/types';

export type TriggerNameFor<P extends string> = P extends KnownProviderName
? KnownTriggerName<P> | (string & {})
? (P extends 'linear' ? KnownTriggerName<P> | LinearAgentWebhookEvent : KnownTriggerName<P>) | (string & {})
: string;

export interface TypedTrigger<P extends string> {
Expand Down
7 changes: 6 additions & 1 deletion packages/persona-kit/src/triggers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ test('lintTriggers returns no issues for known providers and known triggers', ()
{ on: 'pull_request.opened' },
{ on: 'pull_request_review_comment.created' }
],
linear: [{ on: 'issue.create' }],
linear: [
{ on: 'issue.create' },
{ on: 'AgentSessionEvent.created' },
{ on: 'AgentSessionEvent.prompted' },
{ on: 'AppUserNotification.issueCommentMention' }
],
slack: [{ on: 'message.created' }]
})
);
Expand Down
9 changes: 9 additions & 0 deletions packages/persona-kit/src/triggers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { AgentSpec } from './types.js';
import { KNOWN_TRIGGER_CATALOG } from '@relayfile/adapter-core/triggers';
import { LINEAR_AGENT_WEBHOOK_EVENTS } from '@relayfile/adapter-linear/types';

/**
* Known event names per Relayfile provider, used by the deploy CLI to lint
Expand All @@ -24,6 +25,7 @@ const KNOWN_TRIGGER_ALIAS_CATALOG = Object.fromEntries(

export const KNOWN_TRIGGERS = {
...KNOWN_TRIGGER_CATALOG,
linear: mergeKnownTriggers(KNOWN_TRIGGER_CATALOG.linear, LINEAR_AGENT_WEBHOOK_EVENTS),
...KNOWN_TRIGGER_ALIAS_CATALOG
};

Expand Down Expand Up @@ -137,3 +139,10 @@ export function lintTriggers(agent: AgentSpec): TriggerLintIssue[] {
function knownTriggersForProvider(provider: string): readonly string[] | undefined {
return (KNOWN_TRIGGERS as Record<string, readonly string[] | undefined>)[provider];
}

function mergeKnownTriggers(
base: readonly string[] | undefined,
extra: readonly string[]
): readonly string[] {
return [...new Set([...(base ?? []), ...extra])];
}
2 changes: 1 addition & 1 deletion packages/runtime/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@agentworkforce/runtime",
"version": "3.0.40",
"version": "3.0.41",
"private": false,
"type": "module",
"main": "dist/index.js",
Expand Down
83 changes: 82 additions & 1 deletion packages/runtime/src/define-agent.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';

import { defineAgent, isWorkforceAgent, isWorkforceHandler } from './index.js';
import { defineAgent, isWorkforceAgent, isWorkforceHandler, unwrapResourceRecord } from './index.js';
import type {
LinearAgentSessionPayload,
LinearAppUserNotificationPayload
} from './types.js';

test('defineAgent brands the object and wraps the handler', () => {
const agent = defineAgent({
Expand Down Expand Up @@ -62,3 +66,80 @@ test('defineAgent narrows the handler event type to declared triggers/schedules'
}
});
});

test('defineAgent narrows Linear agent-session payloads for declared triggers', () => {
defineAgent({
triggers: {
linear: [
{ on: 'AgentSessionEvent.created' },
{ on: 'AgentSessionEvent.prompted' },
{ on: 'AppUserNotification.issueCommentMention' }
]
},
handler: async (_ctx, event) => {
if (event.source !== 'linear') return;
if (event.type === 'AgentSessionEvent.created') {
const record = unwrapResourceRecord<LinearAgentSessionPayload>(event.payload);
if (!isLinearAgentSessionPayload(record)) return;
const sessionId: string = record.agentSession.id;
const promptContext: string | undefined = record.promptContext;
void sessionId;
void promptContext;
return;
}
if (event.type === 'AgentSessionEvent.prompted') {
const record = unwrapResourceRecord<LinearAgentSessionPayload>(event.payload);
if (!isLinearAgentSessionPayload(record)) return;
const body: string | undefined = record.agentActivity?.body;
void body;
return;
}
const record = unwrapResourceRecord<LinearAppUserNotificationPayload>(event.payload);
if (!isLinearAppUserNotificationPayload(record)) return;
const issueId: string | undefined =
record.notification?.issue?.id ??
record.issue?.id;
const commentBody: string | undefined =
record.notification?.comment?.body ??
record.comment?.body;
void issueId;
void commentBody;
}
});
});

test('unwrapResourceRecord unwraps the real relayfile resource payload container', () => {
const record = unwrapResourceRecord<{
body?: string;
issue_id?: string;
issue_identifier?: string;
}>({
resource: {
provider: 'linear',
objectType: 'comment',
payload: {
body: '@agentrelay please implement this.',
issue_id: '5d6f2e15-0000-4000-8000-000000000000',
issue_identifier: 'AR-70'
}
}
});

assert.equal(isRecord(record), true);
if (!isRecord(record)) return;
assert.equal(record.body, '@agentrelay please implement this.');
assert.equal(record.issue_id, '5d6f2e15-0000-4000-8000-000000000000');
assert.equal(record.issue_identifier, 'AR-70');
});

function isLinearAgentSessionPayload(value: unknown): value is LinearAgentSessionPayload {
return isRecord(value) && isRecord(value.agentSession) && typeof value.agentSession.id === 'string';
}

function isLinearAppUserNotificationPayload(value: unknown): value is LinearAppUserNotificationPayload {
return isRecord(value);
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
21 changes: 20 additions & 1 deletion packages/runtime/src/define-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
WorkforceEvent,
WorkforceHandler,
WorkforceHandlerExport,
LinearAgentSessionEvent,
WorkforceProviderEvent
} from './types.js';

Expand All @@ -35,9 +36,27 @@ type OnLiteralsOf<A> = A extends readonly (infer E)[]

type TriggerOnUnion<Tr> = OnLiteralsOf<NonNullable<Tr[keyof Tr]>>;

type LinearSpecialEvent<O extends string> = Extract<LinearAgentSessionEvent, { type: O }>;

type ProviderEventFor<P extends string, O extends string> = P extends 'linear'
? [LinearSpecialEvent<O>] extends [never]
? Omit<WorkforceProviderEvent, 'type'> & { type: O }
: LinearSpecialEvent<O>
: Omit<WorkforceProviderEvent, 'type'> & { type: O };

type TriggerProviderEvents<Tr> = {
[P in keyof Tr]: P extends string
? OnLiteralsOf<NonNullable<Tr[P]>> extends infer O
? O extends string
? ProviderEventFor<P, O>
: never
: never
: never;
}[keyof Tr];

type NarrowedProviderEvent<Tr> = [TriggerOnUnion<Tr>] extends [never]
? never
: Omit<WorkforceProviderEvent, 'type'> & { type: TriggerOnUnion<Tr> };
: TriggerProviderEvents<Tr>;

/** Distributive union of every schedule `name` literal. */
type ScheduleNameUnion<S> = S extends readonly (infer E)[]
Expand Down
17 changes: 17 additions & 0 deletions packages/runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,23 @@ export type {
WorkforceProviderEvent
} from './types.js';

// Runtime envelope helpers shared by provider-triggered agents.
export {
unwrapResourceRecord
} from './types.js';

export type {
LinearAgentActivity,
LinearAgentActivityType,
LinearAgentSession,
LinearAgentSessionEvent,
LinearAgentSessionEventPayload,
LinearAgentSessionPayload,
LinearAppUserNotificationEventPayload,
LinearAppUserNotificationPayload,
LinearIssueReference
} from './types.js';

// VFS-backed transport helpers. All provider interactions go through these
// — no per-provider client code in the runtime. Handlers use listJsonFiles /
// readJsonFile / writeJsonFile directly against the provider path conventions
Expand Down
107 changes: 106 additions & 1 deletion packages/runtime/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,112 @@ export interface WorkforceProviderEvent extends WorkforceEventBase {
};
}

export type WorkforceEvent = WorkforceCronEvent | WorkforceProviderEvent;
export function unwrapResourceRecord<T = unknown>(payload: unknown): T | unknown {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: The generic return type is effectively lost because T | unknown always collapses to unknown, so callers never get the typed result they requested from unwrapResourceRecord<T>(...). Change the signature to return T (or T | undefined if needed) so the helper preserves the generic contract. [api mismatch]

Severity Level: Major ⚠️
- ⚠️ Linear handlers lose static typing for unwrapped payloads.
- ⚠️ Agent authors require extra guards or type assertions.
Steps of Reproduction ✅
1. In `packages/runtime/src/types.ts:59-62`, `unwrapResourceRecord` is declared as `export
function unwrapResourceRecord<T = unknown>(payload: unknown): T | unknown { … }`.

2. Because `T` is unconstrained, the return type `T | unknown` is equivalent to `unknown`
in TypeScript, so any call like `unwrapResourceRecord<Foo>(...)` is statically typed as
`unknown`, ignoring `Foo`.

3. The helper is re‑exported from `packages/runtime/src/index.ts:8-11` (`export {
unwrapResourceRecord } from './types.js';`), and used in
`packages/runtime/src/define-agent.test.ts:70-38` where it is called as
`unwrapResourceRecord<LinearAgentSessionPayload>(event.payload)` and
`unwrapResourceRecord<{ body?: string; issue_id?: string; issue_identifier?: string }>({
resource: { … }})`.

4. In both usages, the generic argument (`LinearAgentSessionPayload` or the inline object
type) does not affect the inferred return type, which remains `unknown`, forcing callers
to rely on separate type guards or assertions instead of getting the requested generic
shape from `unwrapResourceRecord<T>(...)`.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** packages/runtime/src/types.ts
**Line:** 59:59
**Comment:**
	*Api Mismatch: The generic return type is effectively lost because `T | unknown` always collapses to `unknown`, so callers never get the typed result they requested from `unwrapResourceRecord<T>(...)`. Change the signature to return `T` (or `T | undefined` if needed) so the helper preserves the generic contract.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

const resource = isRecord(payload) && 'resource' in payload ? payload.resource : payload;
return isRecord(resource) && 'payload' in resource ? resource.payload : resource;
}

export interface LinearIssueReference {
id: string;
identifier?: string;
title?: string;
url?: string;
}

export interface LinearAgentSession {
id: string;
issue?: LinearIssueReference;
[key: string]: unknown;
}

export type LinearAgentActivityType =
| 'action'
| 'elicitation'
| 'error'
| 'response'
| 'thought';

export interface LinearAgentActivity {
type?: LinearAgentActivityType | string;
body?: string;
action?: string;
parameter?: string;
result?: string;
}

export interface LinearAgentSessionPayload {
type: 'AgentSessionEvent';
action: 'created' | 'prompted' | string;
agentSession: LinearAgentSession;
agentActivity?: LinearAgentActivity & {
id?: string;
agentSessionId?: string;
content?: LinearAgentActivity;
[key: string]: unknown;
};
issue?: LinearIssueReference;
promptContext?: string;
notification?: {
issue?: LinearIssueReference;
comment?: { id?: string; body?: string; [key: string]: unknown };
[key: string]: unknown;
};
[key: string]: unknown;
}

export interface LinearAppUserNotificationPayload {
type: 'AppUserNotification';
action: 'issueCommentMention' | string;
issue?: LinearIssueReference;
comment?: { id?: string; body?: string; [key: string]: unknown };
notification?: {
issue?: LinearIssueReference;
comment?: { id?: string; body?: string; [key: string]: unknown };
[key: string]: unknown;
};
[key: string]: unknown;
}

export interface LinearWrappedPayload<TRecord> {
resource: {
payload: TRecord;
[key: string]: unknown;
};
[key: string]: unknown;
}

export type LinearAgentSessionEventPayload =
LinearWrappedPayload<LinearAgentSessionPayload> & {
agentSession?: LinearAgentSession;
agentActivity?: LinearAgentSessionPayload['agentActivity'];
issue?: LinearIssueReference;
promptContext?: string;
};

export type LinearAppUserNotificationEventPayload =
LinearWrappedPayload<LinearAppUserNotificationPayload> & {
issue?: LinearIssueReference;
comment?: { id?: string; body?: string; [key: string]: unknown };
notification?: LinearAppUserNotificationPayload['notification'];
};

export type LinearAgentSessionEvent =
| (Omit<WorkforceProviderEvent, 'payload' | 'source' | 'type'> & {
source: 'linear';
type: 'AgentSessionEvent.created' | 'AgentSessionEvent.prompted';
payload: LinearAgentSessionEventPayload;
})
| (Omit<WorkforceProviderEvent, 'payload' | 'source' | 'type'> & {
source: 'linear';
type: 'AppUserNotification.issueCommentMention';
payload: LinearAppUserNotificationEventPayload;
});

export type WorkforceEvent = WorkforceCronEvent | LinearAgentSessionEvent | WorkforceProviderEvent;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: This union includes both a specialized Linear event type and the broad WorkforceProviderEvent that still allows source: 'linear', so the broad member swallows the specialized one during narrowing and payload falls back to unknown in common event.source/event.type checks. Exclude Linear from the generic provider branch (or split provider variants by source) so Linear payload typing is actually preserved. [api mismatch]

Severity Level: Major ⚠️
- ⚠️ Linear events via handler() keep payload as unknown.
- ⚠️ Event narrowing by source and type loses specificity.
- ⚠️ Encourages unsafe `any` casts in Linear handlers.
Steps of Reproduction ✅
1. In `packages/runtime/src/types.ts:44-49`, `WorkforceProviderEvent` is defined with
`source: Exclude<WorkforceEventSource, 'cron'>;`, which includes `'linear'`, and `payload:
unknown`.

2. In `packages/runtime/src/types.ts:148-158`, `LinearAgentSessionEvent` is defined as a
union of two specialized Linear variants with `source: 'linear'`, specific `type` literals
(`'AgentSessionEvent.created' | 'AgentSessionEvent.prompted' |
'AppUserNotification.issueCommentMention'`), and strongly typed `payload` fields
(`LinearAgentSessionEventPayload` / `LinearAppUserNotificationEventPayload`).

3. In `packages/runtime/src/types.ts:160`, `WorkforceEvent` is declared as
`WorkforceCronEvent | LinearAgentSessionEvent | WorkforceProviderEvent`, so a Linear event
with `source: 'linear'` and `type: 'AgentSessionEvent.created'` satisfies both
`LinearAgentSessionEvent` and the generic `WorkforceProviderEvent` branch.

4. The public handler signature in `packages/runtime/src/types.ts:370` and
`packages/runtime/src/handler.ts:27` uses `event: WorkforceEvent`, so in a typical handler
following the documented pattern in `handler.ts:14-24` but checking `event.source ===
'linear'` and `event.type === 'AgentSessionEvent.created'`, TypeScript narrows `event` to
`LinearAgentSessionEvent | (WorkforceProviderEvent & { source: 'linear'; type:
'AgentSessionEvent.created' })`, making `event.payload` effectively
`LinearAgentSessionEventPayload | unknown` which collapses to `unknown` and prevents the
Linear specialization from surfacing under normal `source`/`type` narrowing.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** packages/runtime/src/types.ts
**Line:** 160:160
**Comment:**
	*Api Mismatch: This union includes both a specialized Linear event type and the broad `WorkforceProviderEvent` that still allows `source: 'linear'`, so the broad member swallows the specialized one during narrowing and `payload` falls back to `unknown` in common `event.source/event.type` checks. Exclude Linear from the generic provider branch (or split provider variants by source) so Linear payload typing is actually preserved.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎


function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}

/**
* Result of a harness invocation. The runtime translates whatever the
Expand Down
Loading
Loading