Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/agent-notification-destinations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@outlit/cli": patch
"@outlit/pi": patch
"@outlit/tools": patch
---

Use notification destination ids for Outlit notification tools.
59 changes: 15 additions & 44 deletions packages/cli/src/commands/notify.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,16 @@
import { readFileSync } from "node:fs"
import {
customerToolContracts,
notificationProviderValues,
notificationSeverityValues,
} from "@outlit/tools"
import { customerToolContracts, notificationSeverityValues } from "@outlit/tools"
import { defineCommand } from "citty"
import { authArgs } from "../args/auth"
import { AGENT_JSON_HINT, outputArgs } from "../args/output"
import { getClientOrExit, runTool } from "../lib/api"
import { errorMessage, outputError } from "../lib/output"

type NotificationSeverity = (typeof notificationSeverityValues)[number]
type NotificationProvider = (typeof notificationProviderValues)[number]
type NotificationDestination = {
provider: NotificationProvider
channelId?: string
}

const MAX_DESTINATION_COUNT = 10
const MAX_DESTINATION_CHANNEL_ID_LENGTH = 240
const destinationIdPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
Comment on lines +12 to +13

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

UUID pattern mismatch between CLI and contract schema.

The CLI pattern allows only UUID versions 1-5 ([1-5]), while the contract schema in contracts.ts allows versions 1-8 ([1-8]) plus nil/max UUIDs. This means:

  • A UUID v7 (e.g., 01932c6a-7a6c-7e9d-8a1b-123456789012) would pass server-side validation but fail CLI validation.
  • The nil UUID (00000000-0000-0000-0000-000000000000) explicitly allowed in the contract would fail CLI validation.

Consider aligning the CLI pattern with the contract schema to avoid rejecting valid destination IDs.

🔧 Proposed fix to align with contract schema
 const destinationIdPattern =
-  /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
+  /^([0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/i
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/commands/notify.ts` around lines 12 - 13, The CLI's
destinationIdPattern currently restricts UUID version to [1-5], causing valid
IDs (v7, nil, max) accepted by the contract to be rejected; update the
destinationIdPattern constant to match the contract schema by allowing version
digit [1-8] and explicitly permitting the nil UUID
(00000000-0000-0000-0000-000000000000) and the max UUID
(ffffffff-ffff-ffff-ffff-ffffffffffff) so the CLI validation aligns with
contracts.ts.


function parsePayload(raw: string): unknown {
try {
Expand Down Expand Up @@ -56,40 +48,20 @@ function payloadSizeIsValid(payload: unknown): boolean {
return typeof serialized === "string" && serialized.length <= 100000
}

function parseDestinations(raw: string | undefined): NotificationDestination[] | null | undefined {
function parseDestinationIds(raw: string | undefined): string[] | null | undefined {
if (raw === undefined) {
return undefined
}

const destinations = raw.split(",").map((entry) => entry.trim())
const destinationIds = raw.split(",").map((entry) => entry.trim())
if (
destinations.length > MAX_DESTINATION_COUNT ||
destinations.some((entry) => entry.length === 0)
destinationIds.length > MAX_DESTINATION_COUNT ||
destinationIds.some((entry) => entry.length === 0 || !destinationIdPattern.test(entry))
) {
return null
}

const parsed: NotificationDestination[] = []
for (const destination of destinations) {
const [providerInput, ...channelParts] = destination.split(":")
const provider = providerInput?.trim().toLowerCase()
const channelId = channelParts.join(":").trim()

if (!notificationProviderValues.includes(provider as NotificationProvider)) {
return null
}

if (channelId.length > MAX_DESTINATION_CHANNEL_ID_LENGTH) {
return null
}

parsed.push({
provider: provider as NotificationProvider,
...(channelId.length > 0 ? { channelId } : {}),
})
}

return parsed
return destinationIds
}

export default defineCommand({
Expand All @@ -105,7 +77,7 @@ export default defineCommand({
" outlit notify --title 'Risk found' --markdown '**Risk found**\\n\\n- Customer: acme.com'",
" outlit notify --title 'Risk found' '{\"customer\":\"acme.com\"}'",
" outlit notify --title 'Risk found' --payload-file ./payload.json",
" outlit notify --title 'Escalation' --markdown '**Check this account**' --destination slack:C123",
" outlit notify --title 'Escalation' --markdown '**Check this account**' --destination 00000000-0000-4000-8000-000000000001",
" outlit notify --title 'Escalation' --severity HIGH --message 'Check this account' '{\"customer\":\"acme.com\"}'",
"",
AGENT_JSON_HINT,
Expand Down Expand Up @@ -154,8 +126,7 @@ export default defineCommand({
},
destination: {
type: "string",
description:
"Optional comma-separated destinations in provider[:channelId] form. Supported provider: slack",
description: "Optional comma-separated NotificationDestination ids.",
},
},
async run({ args }) {
Expand Down Expand Up @@ -250,11 +221,11 @@ export default defineCommand({
severity = normalized
}

const destinations = parseDestinations(args.destination)
if (destinations === null) {
const destinationIds = parseDestinationIds(args.destination)
if (destinationIds === null) {
return outputError(
{
message: `--destination must use provider[:channelId] with provider one of: ${notificationProviderValues.join(", ")}`,
message: "--destination must be one or more comma-separated NotificationDestination ids",
code: "invalid_input",
},
json,
Expand Down Expand Up @@ -329,8 +300,8 @@ export default defineCommand({
params.subject = subject
}

if (destinations !== undefined) {
params.destinations = destinations
if (destinationIds !== undefined) {
params.destinationIds = destinationIds
}

return runTool(client, customerToolContracts.outlit_send_notification.toolName, params, json)
Expand Down
15 changes: 9 additions & 6 deletions packages/cli/tests/commands/notify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ describe("notify", () => {
args: {
title: "Risk found",
markdown: " **Risk found**\n\n- Customer: Acme ",
destination: "slack:C456",
destination: "00000000-0000-4000-8000-000000000001",
json: true,
},
} as Parameters<NonNullable<typeof notifyCmd.run>>[0])
Expand All @@ -119,7 +119,7 @@ describe("notify", () => {
expect.objectContaining({
title: "Risk found",
markdown: "**Risk found**\n\n- Customer: Acme",
destinations: [{ provider: "slack", channelId: "C456" }],
destinationIds: ["00000000-0000-4000-8000-000000000001"],
}),
)
} finally {
Expand Down Expand Up @@ -352,7 +352,7 @@ describe("notify", () => {
args: {
title: "Risk found",
markdown: "**Risk found**",
destination: "teams:C123",
destination: "not-a-destination-id",
json: true,
},
} as Parameters<NonNullable<typeof notifyCmd.run>>[0])
Expand All @@ -379,7 +379,10 @@ describe("notify", () => {
args: {
title: "Risk found",
markdown: "**Risk found**",
destination: Array.from({ length: 11 }, (_, index) => `slack:C${index}`).join(","),
destination: Array.from(
{ length: 11 },
(_, index) => `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`,
).join(","),
json: true,
},
} as Parameters<NonNullable<typeof notifyCmd.run>>[0])
Expand All @@ -395,7 +398,7 @@ describe("notify", () => {
}
})

test("destination channelId over 240 characters returns invalid_input", async () => {
test("malformed destination id returns invalid_input", async () => {
const { default: notifyCmd } = await import("../../src/commands/notify")
const exitSpy = mockExitThrow()
const stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true)
Expand All @@ -406,7 +409,7 @@ describe("notify", () => {
args: {
title: "Risk found",
markdown: "**Risk found**",
destination: `slack:${"C".repeat(241)}`,
destination: "00000000-0000-0000-0000-000000000001",
json: true,
},
} as Parameters<NonNullable<typeof notifyCmd.run>>[0])
Expand Down
2 changes: 1 addition & 1 deletion packages/pi/tests/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ describe("createOutlitPiExtension", () => {
) {
expect(tool.parameters.required).toEqual(["title"])
expect(Object.keys(tool.parameters.properties ?? {})).toEqual(
expect.arrayContaining(["title", "markdown", "payload", "destinations"]),
expect.arrayContaining(["title", "markdown", "payload", "destinationIds"]),
)
} else {
throw new Error("Expected notification tool parameters to expose required fields")
Expand Down
33 changes: 7 additions & 26 deletions packages/tools/src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -674,35 +674,16 @@ export const customerToolContracts = {
minLength: 1,
maxLength: 240,
},
destinations: {
description:
"Optional explicit notification destinations. Omit to use the default notifier.",
destinationIds: {
description: "Optional NotificationDestination ids. Omit to use the default notifier.",
minItems: 1,
maxItems: 10,
type: "array",
items: {
type: "object",
properties: {
provider: {
description: "Notification provider",
type: "string",
enum: ["slack"],
},
channelId: {
description: "Provider-specific destination channel ID",
type: "string",
minLength: 1,
maxLength: 240,
},
label: {
description: "Optional destination label",
type: "string",
minLength: 1,
maxLength: 120,
},
},
required: ["provider"],
additionalProperties: false,
type: "string",
format: "uuid",
pattern:
"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
},
},
},
Expand Down Expand Up @@ -847,7 +828,7 @@ export const userListOrderFields = ["last_activity_at", "first_seen_at", "email"
export const schemaTables = ["activity", "customers", "users", "revenue"] as const

export const customerToolContractHash =
"5016d146842452855bfdd9ecc8f6be1a122125d0c392ceff315aeb2e44fefc7f" as const
"03e7ece1d8834b065592368173f5ff46a24ffc50f72d1bb9b0505ec5864f3ff6" as const

export type CustomerToolName = (typeof customerToolNames)[number]
export type CustomerSourceType = (typeof customerSourceTypes)[number]
Expand Down
19 changes: 15 additions & 4 deletions packages/tools/tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ describe("tool contracts", () => {
{
enum?: string[]
type?: string
items?: { properties?: Record<string, { enum?: string[] }> }
items?: { format?: string; type?: string }
}
>

Expand All @@ -140,7 +140,18 @@ describe("tool contracts", () => {
type: "string",
}),
)
expect(properties.destinations?.items?.properties?.provider?.enum).toEqual(["slack"])
expect(properties.destinations).toBeUndefined()
expect(properties.destinationIds).toEqual(
expect.objectContaining({
type: "array",
minItems: 1,
maxItems: 10,
items: expect.objectContaining({
type: "string",
format: "uuid",
}),
}),
)
expect(properties.severity).toEqual(
expect.objectContaining({
enum: ["low", "medium", "high"],
Expand Down Expand Up @@ -213,7 +224,7 @@ describe("createOutlitClient", () => {
title: "Reminder",
markdown: "**Reminder**\n\n- Customer: Acme",
severity: "low",
destinations: [{ provider: "slack", channelId: "C123" }],
destinationIds: ["00000000-0000-4000-8000-000000000001"],
})

expect(fetchMock).toHaveBeenCalledWith("https://example.outlit.test/api/tools/call", {
Expand All @@ -228,7 +239,7 @@ describe("createOutlitClient", () => {
title: "Reminder",
markdown: "**Reminder**\n\n- Customer: Acme",
severity: "low",
destinations: [{ provider: "slack", channelId: "C123" }],
destinationIds: ["00000000-0000-4000-8000-000000000001"],
},
}),
})
Expand Down