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 apps/example/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@ AI_MODEL_PROFILES=
AI_EMBEDDING_MODEL=
AI_VISION_MODEL=
AI_WEB_SEARCH_MODEL=
AI_IMAGE_MODEL=
AGENT_TURN_TIMEOUT_MS=
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
{
"writtenAt": "2026-05-20T23:48:01.611Z",
"writtenAt": "2026-07-23T22:55:00.000Z",
"toolName": "webFetch",
"input": {
"url": "https://docs.slack.dev/changelog/2025/10/7/chat-streaming",
"max_chars": 6000
"max_chars": 12000
},
"output": {
"ok": true,
"status": "success",
"truncated": false,
"url": "https://docs.slack.dev/changelog/2025/10/7/chat-streaming",
"content": "We've introduced a new suite of features to help Slack apps provide an end-user experience typical of LLM tools:\n\n- Slack apps can now stream in their responses to the end user using three new API methods.\n- There are new Block Kit components to allow end users to quickly interact with AI responses.\n- The Node and Python Slack SDKs have new utilities to streamline integration of these new features.\n\nRead on for more info!\n\n### Streaming messages[​](#streaming-messages \"Direct link to Streaming messages\")\n\nThree Web API methods work together to enable Slack apps to provide end users a text streaming experience similar to typical LLM tools:\n\n- The [chat.startStream](/reference/methods/chat.startStream) method starts the text stream.\n- The [chat.appendStream](/reference/methods/chat.appendStream) method appends text to the stream.\n- The [chat.stopStream](/reference/methods/chat.stopStream) method stops the text stream.\n\n### Interacting with AI responses[​](#interacting-with-ai-responses \"Direct link to Interacting with AI responses\")\n\nThree new Block Kit components allow more user interaction with agent responses:\n\n- The [feedback\\_buttons](/reference/block-kit/block-elements/feedback-buttons-element) block element allows users to provide feedback on whether an AI response was positive or negative.\n- The [icon\\_button](/reference/block-kit/block-elements/icon-button-element) provides a way for users to quickly trigger actions like deleting AI responses.\n- The [context\\_actions](/reference/block-kit/blocks/context-actions-block) block provides a container for these interactive elements.\n\n### Supported by Slack SDKs and Bolt framework[​](#supported-by-slack-sdks-and-bolt-framework \"Direct link to Supported by Slack SDKs and Bolt framework\")\n\nThe [Python Slack SDK](/tools/python-slack-sdk) and [Node Slack SDK](/tools/node-slack-sdk) each fully support these new features — with a new `streamer` helper utility to aid developers in tying them all together.\n\nHere's a simplified example in Python:\n\n```\nstreamer = client.chat_stream(\n channel=channel_id,\n recipient_team_id=team_id,\n recipient_user_id=user_id,\n thread_ts=thread_ts,\n)\n\nfor event in returned_message:\n streamer.append(markdown_text=f\"{chunk-received-from-llm}\")\n\nstreamer.stop(blocks=feedback_block)\n\n```\n\nThis utility can also be used in the Bolt frameworks based upon these SDKs. Read more in the _Using AI in apps_ section of your preferred Bolt flavor: [Python](/tools/bolt-python/concepts/ai-apps#text-streaming) or [JavaScript](/tools/bolt-js/concepts/ai-apps#text-streaming).\n\nWe've also updated our app agent template repos to use these new features. Pick your flavor of Bolt and give it a try.\n\nGet started with the [Bolt for Python agent template](https://github.com/slack-samples/bolt-python-assistant-template):\n\n```\nslack create --template slack-samples/bolt-python-assistant-template\nslack run\n\n```\n\nGet started with the [Bolt for JavaScript agent template](https://github.com/slack-samples/bolt-js-assistant-template):\n\n```\nslack create --template slack-samples/bolt-js-assistant-template\nslack run\n\n```\n\nRead all the details in the [Bolt Python release notes](https://github.com/slackapi/bolt-python/releases/tag/v1.26.0) and [Bolt JS release notes](https://github.com/slackapi/bolt-js/releases/tag/%40slack%2Fbolt%404.5.0).",
"content": "We've introduced a new suite of features to help Slack apps provide an end-user experience typical of LLM tools:\n\n- Slack apps can now stream in their responses to the end user using three new API methods.\n- There are new Block Kit components to allow end users to quickly interact with AI responses.\n- The Node and Python Slack SDKs have new utilities to streamline integration of these new features.\n\nRead on for more info!\n\n### Streaming messages[​](#streaming-messages \"Direct link to Streaming messages\")\n\nThree Web API methods work together to enable Slack apps to provide end users a text streaming experience similar to typical LLM tools:\n\n- The [chat.startStream](/reference/methods/chat.startStream) method starts the text stream.\n- The [chat.appendStream](/reference/methods/chat.appendStream) method appends text to the stream.\n- The [chat.stopStream](/reference/methods/chat.stopStream) method stops the text stream.\n\n### Interacting with AI responses[​](#interacting-with-ai-responses \"Direct link to Interacting with AI responses\")\n\nThree new Block Kit components allow more user interaction with agent responses:\n\n- The [feedback\\_buttons](/reference/block-kit/block-elements/feedback-buttons-element) block element allows users to provide feedback on whether an AI response was positive or negative.\n- The [icon\\_button](/reference/block-kit/block-elements/icon-button-element) provides a way for users to quickly trigger actions like deleting AI responses.\n- The [context\\_actions](/reference/block-kit/blocks/context-actions-block) block provides a container for these interactive elements.\n\n### Supported by Slack SDKs and Bolt framework[​](#supported-by-slack-sdks-and-bolt-framework \"Direct link to Supported by Slack SDKs and Bolt framework\")\n\nThe [Python Slack SDK](/tools/python-slack-sdk) and [Node Slack SDK](/tools/node-slack-sdk) each fully support these new features — with a new `streamer` helper utility to aid developers in tying them all together.\n\nHere's a simplified example in Python:\n\n```\nstreamer = client.chat_stream(\n channel=channel_id,\n recipient_team_id=team_id,\n recipient_user_id=user_id,\n thread_ts=thread_ts,\n)\n\nfor event in returned_message:\n streamer.append(markdown_text=f\"{chunk-received-from-llm}\")\n\nstreamer.stop(blocks=feedback_block)\n\n```\n\nThis utility can also be used in the Bolt frameworks based upon these SDKs. Read more in the _Using AI in apps_ section of your preferred Bolt flavor: [Python](/tools/bolt-python/concepts/adding-agent-features/#text-streaming) or [JavaScript](/tools/bolt-js/concepts/adding-agent-features/#text-streaming).\n\nWe've also updated our app agent template repos to use these new features. Pick your flavor of Bolt and give it a try.\n\nGet started with the [Bolt for Python agent template](https://github.com/slack-samples/bolt-python-assistant-template):\n\n```\nslack create --template slack-samples/bolt-python-assistant-template\nslack run\n\n```\n\nGet started with the [Bolt for JavaScript agent template](https://github.com/slack-samples/bolt-js-assistant-template):\n\n```\nslack create --template slack-samples/bolt-js-assistant-template\nslack run\n\n```\n\nRead all the details in the [Bolt Python release notes](https://github.com/slackapi/bolt-python/releases/tag/v1.26.0) and [Bolt JS release notes](https://github.com/slackapi/bolt-js/releases/tag/%40slack%2Fbolt%404.5.0).",
"title": "New features designed for Slack apps sending AI responses | Slack Developer Docs",
"content_type": "text/html; charset=utf-8",
"source_bytes": 63791,
"extracted_chars": 3345,
"truncated": false,
"status": "success",
"ok": true
"source_bytes": 66710,
"extracted_chars": 3375
},
"metadata": {
"cacheKey": "5809ce53de0cd628b4cfbfddc851de84fc86d88418055b1c8f13a6806d556ed5",
"version": "web-fetch-v1",
"mode": "auto"
"cacheKey": "5ffef6a1f8780d6c3ba7c8bd8285a0b8700b18168d186911d4f805933d7206c2",
"version": "web-fetch-v2",
"mode": "record"
}
}

Large diffs are not rendered by default.

Large diffs are not rendered by default.

3 changes: 0 additions & 3 deletions packages/junior-evals/evals/agent/files.eval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,6 @@ describeEval("Coding File Tools", slackEvals, (it) => {
}),
});
expect(result.usage.model).toBe("openai/gpt-5.6-sol");
expect(
toolCalls(result.session).filter((call) => call.name === "handoff"),
).toHaveLength(0);
});

it("routes a coding task to the handoff model and keeps its workspace on the next turn", async ({
Expand Down
4 changes: 2 additions & 2 deletions packages/junior-evals/evals/memory/actors.eval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ describeEval("Memory Multi-Actor Provenance", slackEvals, (it) => {
overrides: memoryPluginOverrides,
initialEvents: [
mention(
"I prefer status updates with risks listed first. Can you draft one for the rollout pause?",
"I prefer status updates with risks listed first. Draft a brief update saying the rollout is paused while we validate the rollback and that the next checkpoint is tomorrow.",
{
thread: conflictingPreferencesThread,
author: ALICE,
Expand Down Expand Up @@ -314,7 +314,7 @@ describeEval("Memory Multi-Actor Provenance", slackEvals, (it) => {
overrides: memoryPluginOverrides,
initialEvents: [
mention(
"Can you list the open questions from this thread when you get a chance?",
"Open question: should we pause the launch? Please list it when you get a chance.",
{
thread: actorPreferenceMultiActorThread,
author: BOB,
Expand Down
2 changes: 1 addition & 1 deletion packages/junior-evals/evals/memory/shared.eval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ describeEval("Shared Memory", slackEvals, (it) => {
}) => {
await clearMemories();
const userText =
"Branch QA runbooks require risk notes before summary notes.";
"For this team, branch QA runbooks require risk notes before summary notes. Please acknowledge.";
const result = await run({
overrides: memoryPluginOverrides,
initialEvents: [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
name: eval-resource-events
description: Use for `/eval-resource-events` requests that create a watchable resource and monitor requested outcomes.
---

# Eval Resource Events

1. Call `searchMcpTools` for provider `eval-resource-events` and find the pull-request creation tool. Do not inspect other providers, installed plugins, or runtime configuration.
2. Call the returned `mcp__eval-resource-events__create-watchable-pull-request` tool with the requested title.
3. Continue the user's request from the returned pull-request result.
3 changes: 3 additions & 0 deletions packages/junior-evals/global-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import setupPostgres from "./postgres-global-setup";
import { startEvalEgress } from "./src/eval-egress";
import type { EvalInvocationContext } from "./src/eval-context";
import { loadEvalPluginFixtures } from "./src/eval-plugin-fixtures";
import { installEvalAiGatewayDispatcher } from "./src/eval-ai-gateway-dispatcher";

type EvalGlobalProject = Parameters<typeof setupPostgres>[0] & {
provide(key: "juniorEvalContext", value: EvalInvocationContext): void;
Expand All @@ -32,6 +33,7 @@ export default async function setup(
project: EvalGlobalProject,
): Promise<() => Promise<void>> {
const teardownPostgres = await setupPostgres(project);
const restoreAiGatewayDispatcher = installEvalAiGatewayDispatcher();
let previousCatalogConfig: ReturnType<typeof pluginCatalogRuntime.setConfig>;
let egress: Awaited<ReturnType<typeof startEvalEgress>> | undefined;
let mswListening = false;
Expand All @@ -49,6 +51,7 @@ export default async function setup(
pluginCatalogRuntime.setConfig(previousCatalogConfig);
},
teardownPostgres,
restoreAiGatewayDispatcher,
]) {
try {
await task();
Expand Down
1 change: 1 addition & 0 deletions packages/junior-evals/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"chat": "4.29.0",
"tinyrainbow": "^3.1.0",
"typescript": "^6.0.3",
"undici": "7.25.0",
"vitest": "^4.1.7",
"vitest-evals": "0.15.0",
"zod": "catalog:"
Expand Down
31 changes: 23 additions & 8 deletions packages/junior-evals/src/behavior-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { Destination } from "@sentry/junior-plugin-api";
import { executeWithReplay } from "vitest-evals/replay";
import type { JsonValue } from "vitest-evals/harness";
import { createSlackRuntime } from "@/chat/app/factory";
import { botConfig } from "@/chat/config";
import { getConversationEventStore, getDb } from "@/chat/db";
import type { AssistantLifecycleEvent } from "@/chat/runtime/slack-runtime";
import type { JuniorRuntimeServiceOverrides } from "@/chat/app/services";
Expand Down Expand Up @@ -71,6 +72,8 @@ import { upsertAgentTurnSessionRecord } from "@/chat/state/turn-session";
import { resetSkillDiscoveryCache } from "@/chat/skills";
import { juniorToolResultSchema } from "@/chat/tool-support/structured-result";
import { annotateTurnDeadlineToolResult } from "@/chat/tool-support/turn-deadline-result";
import { DEFAULT_MAX_CHARS, MAX_FETCH_CHARS } from "@/chat/tools/web/constants";
import { truncateWebFetchContent } from "@/chat/tools/web/fetch-content";
import { createWebFetchTool } from "@/chat/tools/web/fetch-tool";
import { createWebSearchTool } from "@/chat/tools/web/search";
import type {
Expand Down Expand Up @@ -434,10 +437,11 @@ function createReplayWebFetchDeps(

return {
execute: async (input) => {
const args: Record<string, JsonValue> = { url: input.url };
if (input.max_chars !== undefined) {
args.max_chars = input.max_chars;
}
const requestedMaxChars = input.max_chars ?? DEFAULT_MAX_CHARS;
const args: Record<string, JsonValue> = {
url: input.url,
max_chars: MAX_FETCH_CHARS,
};

const { result } = await executeWithReplay({
toolName: "webFetch",
Expand All @@ -461,22 +465,33 @@ function createReplayWebFetchDeps(
return output as JsonValue;
},
replay: {
version: "web-fetch-v1",
version: "web-fetch-v2",
key: (replayArgs) => ({
url: replayArgs.url,
max_chars: replayArgs.max_chars ?? null,
}),
},
});
return juniorToolResultSchema.parse(result);
const parsed = juniorToolResultSchema.parse(result);
if (parsed.ok !== true || typeof parsed.content !== "string") {
return parsed;
}
const limited = truncateWebFetchContent(
parsed.content,
requestedMaxChars,
);
return {
...parsed,
content: limited.content,
truncated: parsed.truncated === true || limited.truncated,
};
},
};
}

function createReplayWebSearchDeps(
baseOverrides: ToolHooks["toolOverrides"],
): WebSearchToolDeps {
const liveTool = createWebSearchTool({
const liveTool = createWebSearchTool(botConfig.webSearchModelId, {
execute: baseOverrides?.webSearch?.execute,
});

Expand Down
41 changes: 41 additions & 0 deletions packages/junior-evals/src/eval-ai-gateway-dispatcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {
getGlobalDispatcher,
setGlobalDispatcher,
type Dispatcher,
} from "undici";
import { GEN_AI_SERVER_ADDRESS, GEN_AI_SERVER_PORT } from "@/chat/pi/client";

const AI_GATEWAY_ORIGIN = new URL(
`https://${GEN_AI_SERVER_ADDRESS}:${GEN_AI_SERVER_PORT}`,
).origin;
// Leaves time for provider retry backoff and a second attempt inside the
// separate 60-second eval reply budget.
export const EVAL_AI_GATEWAY_BODY_TIMEOUT_MS = 25_000;

function requestOrigin(origin: Dispatcher.DispatchOptions["origin"]): string {
if (!origin) return "";
return typeof origin === "string" ? new URL(origin).origin : origin.origin;
}

/** Install an eval-scoped body timeout for AI Gateway provider requests. */
export function installEvalAiGatewayDispatcher(
bodyTimeoutMs = EVAL_AI_GATEWAY_BODY_TIMEOUT_MS,
targetOrigin = AI_GATEWAY_ORIGIN,
): () => Promise<void> {
const previousDispatcher = getGlobalDispatcher();
const normalizedTargetOrigin = new URL(targetOrigin).origin;
const dispatcher = previousDispatcher.compose(
(dispatch) => (options, handler) =>
dispatch(
requestOrigin(options.origin) === normalizedTargetOrigin
? { ...options, bodyTimeout: bodyTimeoutMs }
: options,
handler,
),
);
setGlobalDispatcher(dispatcher);

return async () => {
setGlobalDispatcher(previousDispatcher);
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import http from "node:http";
import { afterEach, describe, expect, it } from "vitest";
import {
getModel,
isRetryableAssistantError,
streamAnthropic,
type Message as PiAiMessage,
} from "@/chat/pi/sdk";
import type { PiMessage } from "@/chat/pi/messages";
import { nextProviderRetry } from "@/chat/services/provider-retry";
import { installEvalAiGatewayDispatcher } from "../../src/eval-ai-gateway-dispatcher";

const openServers = new Set<http.Server>();

async function startStalledServer(): Promise<string> {
const server = http.createServer((_request, response) => {
response.writeHead(200, { "content-type": "text/event-stream" });
response.flushHeaders();
});
openServers.add(server);
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", () => resolve()),
);
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Expected a TCP test server address");
}
return `http://127.0.0.1:${address.port}`;
}

afterEach(async () => {
for (const server of openServers) {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
openServers.clear();
});

describe("eval AI Gateway dispatcher", () => {
it("terminates a response body that stops producing data", async () => {
const targetOrigin = await startStalledServer();
const restore = installEvalAiGatewayDispatcher(100, targetOrigin);

try {
const response = await fetch(targetOrigin);
const reader = response.body?.getReader();
if (!reader) throw new Error("Expected response body reader");

await expect(reader.read()).rejects.toMatchObject({
cause: expect.objectContaining({ code: "UND_ERR_BODY_TIMEOUT" }),
});
} finally {
await restore();
}
});

it("turns a stalled Pi stream into resumable provider history", async () => {
const targetOrigin = await startStalledServer();
const restore = installEvalAiGatewayDispatcher(100, targetOrigin);
const userMessage: PiAiMessage = {
role: "user",
content: [{ type: "text", text: "help" }],
timestamp: Date.now(),
};

try {
const stream = streamAnthropic(
getModel("vercel-ai-gateway", "xai/grok-4.5"),
{ messages: [userMessage] },
{
client: {
messages: {
create: () => ({
asResponse: async () => await fetch(targetOrigin),
}),
},
} as never,
},
);
const failedAssistant = await stream.result();

expect(failedAssistant).toMatchObject({
role: "assistant",
stopReason: "error",
errorMessage: "terminated",
});
expect(isRetryableAssistantError(failedAssistant)).toBe(true);
expect(
nextProviderRetry({
attempt: 0,
messages: [userMessage as PiMessage, failedAssistant as PiMessage],
retryableFailure: isRetryableAssistantError(failedAssistant),
}),
).toEqual({ delayMs: 2_000, messages: [userMessage as PiMessage] });
} finally {
await restore();
}
});
});
Loading
Loading