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
78 changes: 78 additions & 0 deletions src/agent/__tests__/slack-context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, test } from "bun:test";
import { type SlackContext, slackContextStore } from "../slack-context.ts";

const SAMPLE: SlackContext = {
slackChannelId: "C123",
slackThreadTs: "1700000000.000100",
slackMessageTs: "1700000000.000200",
};

describe("slackContextStore", () => {
test("getStore() is undefined outside a run()", () => {
expect(slackContextStore.getStore()).toBeUndefined();
});

test("synchronous read inside run() sees the context", () => {
const seen = slackContextStore.run(SAMPLE, () => slackContextStore.getStore());
expect(seen).toEqual(SAMPLE);
});

test("context propagates across a plain await boundary", async () => {
const seen = await slackContextStore.run(SAMPLE, async () => {
await Promise.resolve();
return slackContextStore.getStore();
});
expect(seen).toEqual(SAMPLE);
});

test("context propagates across a setImmediate hop", async () => {
const seen = await slackContextStore.run(SAMPLE, async () => {
await new Promise<void>((resolve) => setImmediate(resolve));
return slackContextStore.getStore();
});
expect(seen).toEqual(SAMPLE);
});

test("context propagates through an async generator for-await loop", async () => {
async function* producer(): AsyncGenerator<number> {
for (let i = 0; i < 3; i++) {
await Promise.resolve();
yield i;
}
}

const observations: (SlackContext | undefined)[] = [];
await slackContextStore.run(SAMPLE, async () => {
for await (const _ of producer()) {
observations.push(slackContextStore.getStore());
}
});

expect(observations.length).toBe(3);
for (const seen of observations) {
expect(seen).toEqual(SAMPLE);
}
});

test("concurrent run() calls keep contexts isolated", async () => {
const other: SlackContext = {
slackChannelId: "C999",
slackThreadTs: "2700000000.000100",
slackMessageTs: "2700000000.000200",
};

const [a, b] = await Promise.all([
slackContextStore.run(SAMPLE, async () => {
await Promise.resolve();
return slackContextStore.getStore();
}),
slackContextStore.run(other, async () => {
await Promise.resolve();
return slackContextStore.getStore();
}),
]);

expect(a).toEqual(SAMPLE);
expect(b).toEqual(other);
});
});
22 changes: 22 additions & 0 deletions src/agent/slack-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { AsyncLocalStorage } from "node:async_hooks";

/**
* Request-scoped Slack context for the current agent turn.
*
* Populated by the channel router when a Slack-origin message enters the
* runtime, and read by in-process MCP tool handlers that need to target the
* operator's originating message/thread without relying on the agent to
* forward the IDs through tool arguments. This is the minimum-surface
* plumbing that lets tools (e.g. phantom_loop) auto-fill channel/thread when
* the agent omits them.
*
* Non-Slack turns (telegram, email, webhook, cli, scheduled triggers) leave
* the store unset; consumers must treat `getStore()` as possibly undefined.
*/
export type SlackContext = {
slackChannelId: string;
slackThreadTs: string;
slackMessageTs: string;
};

export const slackContextStore = new AsyncLocalStorage<SlackContext>();
4 changes: 2 additions & 2 deletions src/db/__tests__/migrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ describe("runMigrations", () => {
runMigrations(db);

const migrationCount = db.query("SELECT COUNT(*) as count FROM _migrations").get() as { count: number };
expect(migrationCount.count).toBe(11);
expect(migrationCount.count).toBe(12);
});

test("tracks applied migration indices", () => {
Expand All @@ -48,6 +48,6 @@ describe("runMigrations", () => {
.all()
.map((r) => (r as { index_num: number }).index_num);

expect(indices).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
expect(indices).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
});
});
6 changes: 6 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,4 +120,10 @@ export const MIGRATIONS: string[] = [
)`,

"CREATE INDEX IF NOT EXISTS idx_loops_status ON loops(status)",

// Track the operator's originating Slack message so the loop runner can
// drive a reaction ladder on it (hourglass → cycle → terminal emoji).
// Appended, never inserted mid-array: existing deployments have already
// applied migrations 0–10, so the new column must land at index 11.
"ALTER TABLE loops ADD COLUMN trigger_message_ts TEXT",
];
57 changes: 32 additions & 25 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { join, resolve } from "node:path";
import { createInProcessToolServer } from "./agent/in-process-tools.ts";
import { AgentRuntime } from "./agent/runtime.ts";
import type { RuntimeEvent } from "./agent/runtime.ts";
import { slackContextStore } from "./agent/slack-context.ts";
import { CliChannel } from "./channels/cli.ts";
import { EmailChannel } from "./channels/email.ts";
import { emitFeedback, setFeedbackHandler } from "./channels/feedback.ts";
Expand Down Expand Up @@ -426,31 +427,37 @@ async function main(): Promise<void> {
telegramChannel.startTyping(telegramChatId);
}

const response = await runtime.handleMessage(
msg.channelId,
msg.conversationId,
promptText,
(event: RuntimeEvent) => {
switch (event.type) {
case "init":
console.log(`\n[phantom] Session: ${event.sessionId}`);
break;
case "thinking":
statusReactions?.setThinking();
break;
case "tool_use":
statusReactions?.setTool(event.tool);
if (progressStream) {
const summary = formatToolActivity(event.tool, event.input);
progressStream.addToolActivity(event.tool, summary);
}
break;
case "error":
statusReactions?.setError();
break;
}
},
);
const onEvent = (event: RuntimeEvent): void => {
switch (event.type) {
case "init":
console.log(`\n[phantom] Session: ${event.sessionId}`);
break;
case "thinking":
statusReactions?.setThinking();
break;
case "tool_use":
statusReactions?.setTool(event.tool);
if (progressStream) {
const summary = formatToolActivity(event.tool, event.input);
progressStream.addToolActivity(event.tool, summary);
}
break;
case "error":
statusReactions?.setError();
break;
}
};

const runHandle = (): ReturnType<typeof runtime.handleMessage> =>
runtime.handleMessage(msg.channelId, msg.conversationId, promptText, onEvent);

// Slack-origin turns run inside an AsyncLocalStorage scope so in-process
// MCP tools (phantom_loop, etc.) can auto-target the operator's thread
// and original message without relying on the agent to forward the IDs.
const response =
isSlack && slackChannelId && slackThreadTs && slackMessageTs
? await slackContextStore.run({ slackChannelId, slackThreadTs, slackMessageTs }, runHandle)
: await runHandle();

// Track assistant messages
if (response.text) {
Expand Down
Loading