Skip to content

Give Slack threads a persistent Session - #1859

Open
knechtionscoding wants to merge 1 commit into
kelos-dev:mainfrom
datagravity-ai:feat/slack-session-threads
Open

knechtionscoding wants to merge 1 commit into
kelos-dev:mainfrom
datagravity-ai:feat/slack-session-threads

Conversation

@knechtionscoding

@knechtionscoding knechtionscoding commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

What type of PR is this?

/kind api

What this PR does / why we need it:

A Slack-driven agent starts over between messages. Each message spawns a fresh Task that runs the agent with no conversation history, so iterating in a thread does not work the way iterating in a Claude Code conversation does: you re-explain context every turn.

Session and SessionSpawner already cover a conversation's lifecycle, but nothing could drive a turn from outside a browser, and nothing connected Slack to any of it. This PR adds both halves. It is the bottom of a two-PR stack; the next PR names the channel and thread in every prompt the bridge submits.

A turn driver, internal/sessionturn. There is no request/response API for a Session turn: the runtime listens on a unix socket inside the Session Pod, and the console server reaches it by pods/exec-ing /kelos/bin/kelos-session-runtime. This package speaks that protocol over the same bridge. It subscribes, submits one message request with a request ID, learns its turn ID from the echoed user message, follows only that turn's events, and returns the assistant reply on turn.completed. It handles a turn already in flight (the runtime queues behind it), a prompt merged into an already-queued turn, a stream that drops mid-turn, and a 60-minute bound on one turn. WaitReady resolves a Session to its Pod, requesting a resume when the idle policy suspended it.

The Slack ingress. SessionSpawner.spec.when gains a slack source, mutually exclusive with githubWebhook, reusing the channel, trigger, exclusion and bot-message rules TaskSpawner's Slack source already has. sessionTemplate.initialPrompt and sessionTemplate.suspend are rejected for a Slack source: every matching thread message already runs as its own turn, so an initial prompt would only delay the message that asked for something, and a suspended Session cannot answer the thread that created it.

Every prompt the bridge submits begins with a fixed envelope naming the author. That is load-bearing, not cosmetic: the runtime parses a prompt for its own commands before the agent sees it, and text beginning with ! runs as a shell command in the Session Pod with the Session's credentials.

The Slack server maps channel + thread_ts to a deterministic Session name (hashed, since a Slack timestamp contains a dot), creates the Session on the thread's first matching message and reuses it for every later matching message in that thread. An unmatched reply is ignored and never reaches the Session. Each thread gets its own serial queue, so a message arriving mid-turn waits rather than opening a second stream into the same Session; up to eight may wait before the bot says so in the thread.

What it looks like in Slack. The bot posts a :hourglass: Working on your request... reply into the thread, then edits that same message in place with the answer once the turn finishes. A reply too long for one Slack message continues in further thread replies. A failed turn, an interrupted turn, or a Session that never becomes ready reports the reason in the same place rather than leaving the thread waiting.

Which issue(s) this PR is related to:

N/A. This is #1772 rebased onto current main; that PR was opened from our fork's production branch by mistake and closed.

Special notes for your reviewer:

Decisions worth a second opinion:

  • Where the driver runs. In the Slack server process, not a separate controller. A turn does not survive a Slack server restart; the Session and its history do, so the next message in the thread picks up where it left off.
  • Concurrency within a thread. Queued, in arrival order, one at a time. The runtime also merges a prompt into an already-queued turn, which the driver follows rather than fighting.
  • Lifecycle. Every matching thread gets a Pod and, with a volumeClaimTemplate, a PVC. idlePolicy is the lever; the example sets suspendAfterSeconds: 1800 and deleteAfterSeconds: 86400.
  • Scope. spec.when.slack.channels keeps the first deploy to one channel.
  • A TaskSpawner and a SessionSpawner that both match a message each act on it, producing two replies. Documented; not enforced.

RBAC: the Slack server now needs create on pods/exec, get/list/watch on sessionspawners, and create/get/list/watch/patch on sessions. The chart grants these.

Since #1772 the Session provider contract moved turn completion onto the event sink (SetEventSink + StartTurn), so the echo provider in internal/sessionturn/integration_test.go was ported to it; the driver itself needed no change, since it speaks the socket protocol rather than the provider interface.

No UI changes, so no screenshots. make update produces no diff, and make verify, make test and make test-integration (181 + 12 install specs) pass locally.

Does this PR introduce a user-facing change?

SessionSpawner now accepts a `spec.when.slack` source. A matching Slack thread gets its own persistent Session: the first matching message creates it and every later matching message in that thread runs as another turn in the same conversation, so the agent keeps what it has already been told. A reply that does not match (by default, one that does not @-mention the bot) is ignored and never reaches the Session. The reply is posted into the thread and edited in place as the turn finishes. `spec.sessionTemplate.initialPrompt` and `spec.sessionTemplate.suspend` are rejected for a Slack source.

🤖 Generated with Claude Code


Summary by cubic

Slack threads now keep a persistent Session instead of starting a fresh Task per message, so the agent retains conversation history across turns instead of re-explaining context each time.

Adds internal/sessionturn, which drives one conversation turn over the same pods/exec bridge the console server uses, and a spec.when.slack source on SessionSpawner that reuses the existing Slack matching rules from TaskSpawner. The Slack server maps channel and thread to a deterministic Session, creates it on the thread's first matching message, and runs every later matching message as another turn in the same conversation. Provider questions are declined so a turn cannot stall mid-ask, and an abandoned turn is withdrawn — interrupted if running, removed if queued — unless it also carries another client's prompt.

Slack behavior

  • The bot posts a placeholder thread reply that it edits in place with the answer; long replies continue in further thread replies, and failures report the reason in the thread. A graceful shutdown resolves any placeholder left behind.
  • Each thread queues turns one at a time; up to eight may wait before further messages are dropped with a note in the thread.
  • Unmatched replies are ignored and never reach the Session.
  • Prompts beginning with ! run as a shell command in the Session Pod, so the bridge's fixed prompt envelope — which keeps nothing user-controlled first — is load-bearing.

Validation and operations

  • spec.when now requires exactly one of githubWebhook or slack; initialPrompt and suspend are rejected for a Slack source, an idle policy is required, and a later message in the thread resumes a suspended Session.
  • The Slack server needs new RBAC (pods/exec create plus reads and writes on Sessions and SessionSpawners); the chart grants it.
  • Turn timeout (default 60m) and the queued-turns bound (default 8) are server flags configurable through chart values.
  • A turn doesn't survive a Slack server restart, but the Session and its history do, so the next message in the thread resumes the conversation.
  • A TaskSpawner and a SessionSpawner that both match a message each reply; documented, not enforced.

Written for commit 2edfccd. Summary will update on new commits.

View guided diff

An agent driven from Slack starts over between messages. Each message spawns a fresh Task
with no conversation history, so iterating in a thread means re-explaining
context every turn. Session and SessionSpawner already covered a conversation's
lifecycle, but nothing could drive a turn from outside a browser, and nothing
connected Slack to any of it.

internal/sessionturn drives one turn. The Session runtime exposes no
request/response API for a turn: it listens on a unix socket inside the Session
Pod, which the console server reaches by pods/exec-ing kelos-session-runtime.
This package speaks that protocol over the same bridge — subscribe, submit one
message, follow only that turn's events, return the assistant reply. It mirrors
the runtime's own history projection when collecting that reply, because
OpenCode streams deltas and never sends a closing assistant.message. It handles
a turn already in flight, a prompt merged into a queued turn, a queued prompt
withdrawn from the console, a dropped stream, and a bound on how long a turn
may run. A provider question is declined, because a thread cannot answer one
mid-turn and an unanswered question would stall the turn while holding the
runtime. A turn the driver abandons is withdrawn — interrupted if it started,
removed if it is still queued — unless it carries another client's prompt too,
in which case it is left alone.

SessionSpawner.spec.when gains a slack source, mutually exclusive with
githubWebhook, reusing the Slack matching rules TaskSpawner already has.
initialPrompt and suspend are rejected there: every matching thread message
already runs as its own turn, and a suspended Session cannot answer the thread
that created it. An idle policy is required, because every matching thread gets
a Pod and a volume that outlive the conversation.

The Slack server maps channel and thread timestamp to a deterministic Session
name, creates the Session on the thread's first matching message, and reuses it
for every later matching message. Each thread has its own serial queue, so a
message arriving mid-turn waits rather than opening a second stream into the
same Session. While a turn runs the bot posts a placeholder into the thread and
edits it in place with the result; a graceful shutdown resolves any placeholder
left behind rather than leaving the thread showing work that is no longer
running.

Every prompt opens with a literal. The runtime parses a submitted prompt for its
own commands before the agent sees it — text beginning with "!" runs as a shell
command inside the Session Pod with the Session's credentials — so no part of a
Slack message that its author controls, including their display name, may come
first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

This branch is waiting to be deployed

1 waiting deployment
ok-to-test — 2edfccd2 Waiting Oct 29, 2026 by knechtionscoding via fork-e2e / e2e-with-environment #1991
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kind/api Categorizes issue or PR as related to API changes needs-actor needs-priority needs-triage release-note

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant