Skip to content

Simplify Junior around one core Conversation and optional Location #1563

Description

@sentry-junior

Junior is the core agent. It owns every Conversation, Turn, and Run. The
Conversation API, local CLI, child work, task work, dispatch work, and providers
all use this agent. Slack adds input, context, and Delivery to a Junior
Conversation. It does not create another agent runtime.

Intent

Use one small interface from stored input through each Run. Use the terms
Conversation, Source, Actor, Location, Delivery, and Destination with one meaning
each. Remove fields that copy the same fact or join facts that can vary
independently.

The types are the main design. Behavior must follow them.

Core model

Input and Source

  • Every Inbound message has one required Source.
  • Source says what produced that input. Examples include a Slack message, web
    input, local CLI input, Resource event, Scheduled task, Event task, Plugin
    dispatch, and Agent invocation.
  • Source uses kind to say what produced the input. Do not use platform because many
    Source kinds are not providers.
  • Provider Source kinds may keep identifiers for their input, such as a Slack
    message timestamp. Source does not contain the Conversation Location.
  • When an input starts a Turn, the worker saves its Source and Actor on that
    Turn. Other batched or steering inputs retain their own Source.
  • Resume restores the Source and Actor saved for the Turn.

Conversation and Location

  • Every Conversation belongs to Junior.
  • A Conversation may have one parent Conversation, identified by
    parentConversationId.
  • A Conversation has zero or one complete Location stored in SQL.
  • Location names one place outside Junior where the Conversation can be
    delivered. For Slack, it identifies the workspace, channel, and thread when a
    thread exists. Channel-level Slack Locations may omit threadTs.
  • The parent relation and Location are independent. A child does not copy its
    parent Location.
  • A Run carries the Conversation Location when the agent or tools need it.
  • Location does not allow output to be sent. Conversation visibility is
    separate.

Turn

  • A Turn stores the Source and Actor selected from the input that started it.
  • The Conversation always stores each completed assistant Message.
  • Do not store a per-message or per-Turn delivery choice. It would add state to
    mailbox, Turn checkpoint, and resume contracts before we have behavior that
    needs the same input kind to make different choices.
  • If that behavior becomes necessary, add an explicit choice for the proven
    case instead of keeping one on every Message and Turn now.

Run and Delivery

  • Delivery is an optional function that sends Run output to the Conversation
    Location.
  • Before every new or resumed Run, the work owner supplies Delivery when its
    work must return to a provider.
  • Slack input gets Slack Delivery.
  • Web or local input gets no provider Delivery, including web input in a
    Slack-linked Conversation.
  • A Resource event gets Delivery from the Conversation Location when it has
    one.
  • A Scheduled task, Event task, or Plugin dispatch uses its explicit
    Destination to create the Conversation Location. The dispatch work owner
    supplies Delivery for that Location.
  • An Agent invocation gets no Delivery. Its Run may carry the parent Location
    when tools need it.
  • Delivery is created for the Location. It does not contain another copy of
    Location.
  • Source and Delivery are independent. Source says what caused the Run. The
    work owner decides whether to supply Delivery.
  • Core does not select a provider from Source or Actor.
  • publishExternally is a legacy stored mailbox and Turn checkpoint field.
    Keep it only while deployed readers require it. Current runtime contracts do
    not use it.

Destination

  • Destination is an explicit target for output or a side effect.
  • A feature may use Destination before a Conversation exists. A Scheduled task
    or Event task can use a Slack Destination to choose where it creates a new
    Conversation. That target then becomes the Conversation Location.
  • A feature may also use Destination when it deliberately targets a place other
    than the Conversation Location.
  • Destination is not part of the core agent contract.

Final interface

These are the relevant fields, not complete type definitions:

type Source =
  | SlackSource
  | WebSource
  | LocalSource
  | ResourceEventSource
  | ScheduledTaskSource
  | EventTaskSource
  | PluginDispatchSource
  | AgentInvocationSource;

type Conversation = {
  conversationId: string;
  parentConversationId?: string;
  location?: Location;
  visibility?: ConversationPrivacy;
};

type InboundMessage = {
  source: Source;
  actor?: Actor;
  input: AgentInput;
};

type Turn = {
  turnId: string;
  source: Source;
  actor?: Actor;
};

type Delivery = (message: AssistantMessage) => void | Promise<void>;

type AgentRun = {
  conversationId: string;
  turnId: string;
  source: Source;
  actor?: Actor;
  location?: Location;
  delivery?: Delivery;
};

The final AgentRun has no Destination, destinationVisibility, or
publishExternally. It carries Location once. Source and Delivery do not repeat
it. Do not add a routing object, wrapper, registry, or alias for these values.

Required behavior

Input Conversation Location Run Delivery
Slack message Slack thread Slack
Local CLI input none none
Web input in a new Junior Conversation none none
Web input in a Slack-linked Conversation Slack thread none
Resource subscription wake existing Conversation Location provider Delivery for that Location, when present
Scheduled task, Event task, or Plugin dispatch targeting Slack Slack Location created from the explicit Destination (threadTs optional for channel-level targets) Slack
Agent invocation parent Location when tools need it none

System wakes must start a Turn from Conversation Location + saved Source/Actor +
mailbox input. They must not invent a Slack Message/Thread, and they must not
require threadTs when the Location is channel-level.

Add or extend the main integration scenarios for these behaviors. Do not add one
test for each implementation branch.

Also cover:

  • Slack replies stay in the same thread when Location has threadTs.
  • Channel-level Slack Locations (no threadTs) still accept system wakes and can
    deliver without a synthetic webhook Message.
  • A paused or authorization resume restores the saved Source, Actor, and
    Conversation Location. The work owner rebuilds the same Delivery.
  • Batched and steering inputs keep their own Sources.
  • A child Conversation stores parentConversationId and does not copy the
    parent Location. Its Run may read the parent Location without Delivery.
  • Storing a completed assistant Message does not require Delivery.

Current conflicts

  • Resource events have a first-class Source after ref(chat)!: add Resource event Source #1730. Scheduled tasks,
    Event tasks, Plugin dispatches, and Agent invocations still need their own
    Source kinds through every Run.
  • Scheduled and Event tasks build a Slack Source even though the task or event
    caused the Turn. Event-task ingest often builds that Slack Source without
    threadTs for channel-level Destinations.
  • Conversation Location is still copied through Destination and
    sessionSource.
  • The current Run contract copies Location into Source and Delivery, which
    requires cross-field consistency checks.
  • Conversation-only web work uses Delivery to store the assistant Message.
    Conversation storage and provider Delivery are not yet independent.
  • Slack system wakes still depend on slack-resource-event.ts: a temporary
    bridge that invents Message/Thread from Conversation routing. That bridge
    requires threadTs. When Location/Source has none (common for event-task and
    channel-level bindings), resolveSlackResourceEventThread cannot deliver.
  • JUNIOR-90 production RCA
    (2026-08-27 → ongoing until 0.188.0 is deployed):
    after
    #1706 (fix(resource-events): let conversations own watch routing, shipped in 0.186.0 via
    junior-prod #353), plain
    resource-event wakes no longer carry Slack thread metadata on the mailbox row.
    Delivery must come from Conversation routing. Missing threadTs caused
    Conversation … is missing a Slack thread for resource-event delivery, then
    worker retries and dead-letter noise (~44k handled errors; almost all non-Zod
    prod volume on POST /api/internal/agent/continue, mostly
    agent-dispatch:dispatch_*). Delivery was already impossible; the throw only
    multiplied Sentry/worker churn. Metric-alert “resolved” on that series was
    dynamic flapping, not a fix.
  • #1721 (fix(chat): ack-drop resource wakes missing Slack thread, in 0.188.0) is a temporary
    stopgap
    : return undefined, warn
    conversation.work.resource_event.missing_thread, ack once. It stops the
    retry storm. It does not restore automation for threadless Slack
    Locations. Prod was still on 0.187.0 when this was written; deploy is
    junior-prod #355.
  • Real fix remains deleting the synthetic bridge under this issue so Slack
    system turns start from Conversation Location without inventing a thread.

Fields and behavior to remove

  • Source.platform as the Source discriminant. Replace it with Source kind.
  • Source.location and Delivery.location. Use AgentRun.location once.
  • Conversation destination when it names the Conversation Location.
  • AgentRun.destination.
  • The invented local Destination for Conversation API work.
  • destinationVisibility. Read Conversation visibility.
  • sessionSource. Save Source with the input and Turn. Save Location on the
    Conversation.
  • turn-session-routing. Remove the mixed Source, Location, and Destination
    interface after each owner reads its own fact.
  • AgentRun.publishExternally and the per-message delivery choice. Each work
    owner supplies Delivery directly. Keep the old Redis field only for deployed
    readers during rollout.
  • Delivery callbacks that only store Conversation Messages. Conversation
    storage is part of the core Turn lifecycle.
  • packages/junior/src/chat/task-execution/slack-resource-event.ts and the
    plain-resource synthetic Message/Thread branch in Slack work. Remove once
    Slack system turns start from Conversation Location + mailbox input (see
    JUNIOR-90 / fix(chat): ack-drop resource wakes missing Slack thread #1721 stopgap above).

Keep Destination only where a feature owns an explicit target. Keep compatibility
only for stored SQL, Redis, queue, or public data that can exist during a
deployment.

Ownership

  • Junior owns Conversation storage, Turn state, model execution, tools, agent
    history, completed assistant Messages, compaction, handoff, sandbox state,
    cancellation, timeouts, and the common pause and resume contract.
  • The Conversation API owns access checks, web input, mailbox admission, and API
    errors.
  • Slack owns Slack input, identity, context, policy, progress, formatting,
    Delivery, and Slack errors.
  • Input owners choose Source and Actor for the Inbound message they create. The
    worker saves those facts on the Turn.
  • Work owners decide whether a Run gets Delivery. Providers build their
    Delivery function. They do not own another model loop or runtime.

Migration rules

  • Make one small behavior or contract change per pull request.
  • Use hard cutovers for internal TypeScript names and signatures.
  • Keep compatibility only for stored SQL, Redis, queue, or public data that can
    exist during deployment.
  • Keep old SQL column and table names behind the storage adapter until deployed
    readers and writers use the new contract.
  • Do not add live aliases or scan old storage on every message.
  • Each removal TODO names its owner, the old field or behavior, and the condition
    that makes removal safe.
  • Do not add a provider registry, adapter framework, manager, dependency bag, or
    parallel runtime.
  • Do not treat ack-drop / warn-and-skip of undeliverable system wakes as the end
    state for threadless Slack Locations. That only bounds failure while the plain
    system Turn path is missing.

Delivery plan

  1. Complete the stored Conversation Location. For Slack, include teamId,
    channelId, and threadTs when present. Treat missing threadTs as a valid
    channel-level Location, not an error.
  2. Expose optional parentConversationId directly on Conversation. Keep the
    parent relation independent from Location. Do not copy the parent Location.
    Done in ref(chat): Store parent on Conversation directly #1718.
  3. Keep one optional Location on AgentRun. Remove nested Location from Source
    and Delivery. Temporary Run Location step landed in ref(chat): pass Conversation Location into agent runs #1715; finish by removing
    nested copies.
  4. Give every Inbound message a complete Source with a kind. Resource event
    Source landed in ref(chat)!: add Resource event Source #1730. Add first-class Source kinds for Scheduled tasks,
    Event tasks, Plugin dispatches, and Agent invocations. Stop rewriting those
    wakes as Slack/local Source stand-ins.
  5. Save Source and Actor on the Turn. Restore them for every resume.
  6. Make each work owner supply optional Delivery before every new or resumed
    Run. Slack input supplies Slack Delivery. Web input supplies none. Resource
    events use the Conversation Location. Scheduled tasks, Event tasks, and
    Plugin dispatches use their explicit Destination to create the Conversation
    Location, then supply Delivery for it. Agent invocations supply none. Slack
    Delivery must work for Locations with or without threadTs
    without synthesizing chat-adapter Message/Thread for system wakes. Delete
    slack-resource-event.ts when that entry exists; retire the fix(chat): ack-drop resource wakes missing Slack thread #1721 ack-drop
    branch with it.
  7. Store completed assistant Messages in the core Turn lifecycle. Keep provider
    Delivery separate.
  8. Move remaining place readers from Destination and sessionSource to
    Location. Remove turn-session-routing.
  9. Remove AgentRun.destination, destinationVisibility, the fake local
    Destination, and AgentRun.publishExternally.
  10. Migrate legacy durable field names after deployed readers and writers use
    the final contract.

Outcome

The work is complete when:

  • Every caller uses one Junior agent runtime.
  • Every Inbound message has a Source that says what produced it.
  • Every Turn saves its Source and Actor.
  • No Inbound message or Turn stores a delivery choice.
  • Storage and the Run interface agree that a Conversation has at most one
    Location.
  • AgentRun carries that Location once.
  • Source does not contain Conversation Location.
  • Delivery does not contain Conversation Location.
  • The agent has no required Destination and no invented local provider model.
  • Provider Delivery is explicit and independent from Source.
  • Conversation storage does not depend on provider Delivery.
  • Slack system wakes (resource events, event tasks, and peers) start without a
    fake Message/Thread and without requiring threadTs on channel-level
    Locations.
  • slack-resource-event.ts and the JUNIOR-90 ack-drop stopgap are gone because
    the plain path makes them unnecessary.
  • A maintainer can follow the types from input through Conversation, Turn, and
    Run without translating between duplicate names.
  • The change removes more concepts and branches than it adds.

Requested by David Cramer.

--

View Junior Session [Sentry]

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions