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
18 changes: 18 additions & 0 deletions .changeset/coder-session-resource.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@electric-ax/agents': minor
'@electric-ax/agents-runtime': minor
'@electric-ax/agents-server-ui': minor
---

feat(coder): split the coder entity into a thin wrapper + a coding-session resource

The coder entity used to own the session's full history on its own collections (`sessionMeta`, `cursorState`, `events`), which coupled the durable session state to a single entity instance. With this change the history (`transcript` + `sessionInfo`) lives on a standalone shared-state resource at a stable id (`coder-session/<entityId>`), and the wrapper entity tracks only its own run lifecycle (`runStatus`, `inboxCursor`).

Why: this is the prerequisite for forking a session, attaching multiple wrappers to the same history, sharing a coder URL across devices/users, and surfacing the same session through specialised viewers — all without entangling those use cases with the SDK runner that produces events.

Visible API additions on `@electric-ax/agents-runtime`:

- `codingSessionResourceSchema`, `codingSessionResourceId(entityId)`, `CODER_RESOURCE_TAG` — the resource schema + id helpers.
- `CodingSessionInfoRow`, `CodingSessionTranscriptRow`, `CodingSessionResourceSchema` — row + schema types.

Removed (clean break, pre-1.0): `CODING_SESSION_META_COLLECTION_TYPE`, `CODING_SESSION_CURSOR_COLLECTION_TYPE`, `CODING_SESSION_EVENT_COLLECTION_TYPE`. Coders created by older versions are not migrated; new coders use the new layout.
95 changes: 95 additions & 0 deletions packages/agents-runtime/src/coding-session-resource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* Schema and helpers for the coding-session **resource** — the durable,
* shareable, forkable representation of a coder's state.
*
* Background. Originally the coder entity owned three of its own
* collections (`sessionMeta`, `cursorState`, `events`) and the entity
* was the canonical home for the session's history. That couples the
* history to one entity instance — fine for "open a coder, send some
* prompts" but awkward when you want to fork the session, attach a
* second entity to it, share a session URL, or surface it in a
* specialised viewer outside any one entity's lifecycle.
*
* The resource pattern fixes that. The history (events + the static
* facts about *which* session this is) lives in a shared-state DB at
* a stable id (`coder-session/<entityId>`). The wrapper coder entity
* just observes/appends to it. Because shared-state DBs are
* server-side first-class streams, multiple entities can attach, the
* stream survives the entity, and the server already knows how to
* fork-rewrite shared-state ids when entities are forked.
*/
import { z } from 'zod'
import type { SharedStateSchemaMap } from './types'

/** Collection event-type strings (mirror of the entity-collection naming convention). */
export const CODING_SESSION_RESOURCE_INFO_TYPE = `coding_session_info`
export const CODING_SESSION_RESOURCE_TRANSCRIPT_TYPE = `coding_session_transcript`

/**
* Static facts about a coding session that don't change as it runs.
* `nativeSessionId` becomes set once the CLI assigns one (after the
* first turn for a fresh session, or up front for an attached/imported
* one). `electricSessionId` matches the slug of the wrapper entity
* that originally created the resource.
*/
export const codingSessionInfoRowSchema = z.object({
key: z.literal(`current`),
agent: z.enum([`claude`, `codex`]),
cwd: z.string(),
electricSessionId: z.string(),
nativeSessionId: z.string().optional(),
createdAt: z.number(),
})
export type CodingSessionInfoRow = z.infer<typeof codingSessionInfoRowSchema>

/**
* One normalized event from the agent-session-protocol stream. Same
* shape the entity used to write into its events collection. Lives
* under the resource's `transcript` collection — *not* `events`,
* because the runtime's `ObservationHandle` reserves the field name
* `events` (for raw `ChangeEvent`s) and would silently shadow a
* collection with that name when we attach via `observe(db(...))`.
*/
export const codingSessionTranscriptRowSchema = z.object({
key: z.string(),
ts: z.number(),
type: z.string(),
callId: z.string().optional(),
payload: z.looseObject({}),
})
export type CodingSessionTranscriptRow = z.infer<
typeof codingSessionTranscriptRowSchema
>

/**
* The shape of a coding-session resource. Both collections live on a
* single shared-state DB — there's no reason to split them, and
* keeping them together lets observers attach with one `db(...)` call.
*/
export const codingSessionResourceSchema = {
sessionInfo: {
schema: codingSessionInfoRowSchema,
type: CODING_SESSION_RESOURCE_INFO_TYPE,
primaryKey: `key`,
},
transcript: {
schema: codingSessionTranscriptRowSchema,
type: CODING_SESSION_RESOURCE_TRANSCRIPT_TYPE,
primaryKey: `key`,
},
} as const satisfies SharedStateSchemaMap

export type CodingSessionResourceSchema = typeof codingSessionResourceSchema

/**
* Default resource id for a coder entity. The wrapper entity stores
* this on its tags as `coderResource` so observers (e.g. the UI) can
* look up the entity, read the tag, and connect to the resource
* stream without needing a separate registry.
*/
export function codingSessionResourceId(entityId: string): string {
return `coder-session/${entityId}`
}

/** Tag key used by the coder entity to point at its resource. */
export const CODER_RESOURCE_TAG = `coderResource`
64 changes: 58 additions & 6 deletions packages/agents-runtime/src/context-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,16 @@ import { CACHE_TIERS } from './types'
import {
CODING_SESSION_ENTITY_TYPE,
codingSessionEntityUrl,
db,
} from './observation-sources'
import {
codingSessionResourceId,
codingSessionResourceSchema,
} from './coding-session-resource'
import type {
CodingSessionInfoRow,
CodingSessionResourceSchema,
} from './coding-session-resource'
import type { ChangeEvent } from '@durable-streams/state'
import type {
AgentConfig,
Expand Down Expand Up @@ -588,19 +597,62 @@ export function createHandlerContext<TState extends StateProxy = StateProxy>(
)

const entityUrl = codingSessionEntityUrl(sessionId)
// The session's history (events) and static facts (agent, cwd,
// nativeSessionId) live on a coding-session resource, not on
// the entity. Attach to it so the handle can surface the same
// `events` / `meta` shape the caller used to get from the
// entity's collections directly.
const resourceId = codingSessionResourceId(sessionId)
const resourceHandle = (await config.doObserve(
db(resourceId, codingSessionResourceSchema)
)) as unknown as SharedStateHandle<CodingSessionResourceSchema>

const readEvents = (): Array<CodingSessionEventRow> => {
const collection = entityHandle.db?.collections.events
if (!collection) return []
const rows = (collection as { toArray?: unknown }).toArray
const rows = resourceHandle.transcript.toArray
return (Array.isArray(rows) ? rows : []) as Array<CodingSessionEventRow>
}
const readMeta = (): CodingSessionMeta | undefined => {
const collection = entityHandle.db?.collections.sessionMeta
const readSessionInfo = (): CodingSessionInfoRow | undefined => {
return resourceHandle.sessionInfo.get(`current`) as
| CodingSessionInfoRow
| undefined
}
const readRunStatus = ():
| {
status: CodingSessionStatus
error?: string
currentPromptInboxKey?: string
}
| undefined => {
const collection = entityHandle.db?.collections.runStatus
if (!collection) return undefined
const row = (collection as { get?: (k: string) => unknown }).get?.(
`current`
)
return row as CodingSessionMeta | undefined
return row as
| {
status: CodingSessionStatus
error?: string
currentPromptInboxKey?: string
}
| undefined
}
const readMeta = (): CodingSessionMeta | undefined => {
const info = readSessionInfo()
if (!info) return undefined
const runStatus = readRunStatus()
return {
electricSessionId: info.electricSessionId,
...(info.nativeSessionId !== undefined
? { nativeSessionId: info.nativeSessionId }
: {}),
agent: info.agent,
cwd: info.cwd,
status: runStatus?.status ?? `initializing`,
...(runStatus?.error !== undefined ? { error: runStatus.error } : {}),
...(runStatus?.currentPromptInboxKey !== undefined
? { currentPromptInboxKey: runStatus.currentPromptInboxKey }
: {}),
}
}
const MESSAGE_TYPES = new Set([`user_message`, `assistant_message`])

Expand Down
17 changes: 14 additions & 3 deletions packages/agents-runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,6 @@ export {

export {
CODING_SESSION_ENTITY_TYPE,
CODING_SESSION_META_COLLECTION_TYPE,
CODING_SESSION_CURSOR_COLLECTION_TYPE,
CODING_SESSION_EVENT_COLLECTION_TYPE,
codingSession,
codingSessionEntityUrl,
entity,
Expand All @@ -208,6 +205,20 @@ export {
tagged,
db,
} from './observation-sources'
export {
CODER_RESOURCE_TAG,
CODING_SESSION_RESOURCE_INFO_TYPE,
CODING_SESSION_RESOURCE_TRANSCRIPT_TYPE,
codingSessionInfoRowSchema,
codingSessionResourceId,
codingSessionResourceSchema,
codingSessionTranscriptRowSchema,
} from './coding-session-resource'
export type {
CodingSessionInfoRow,
CodingSessionResourceSchema,
CodingSessionTranscriptRow,
} from './coding-session-resource'
export type {
EntityObservationSource,
CronObservationSource,
Expand Down
12 changes: 0 additions & 12 deletions packages/agents-runtime/src/observation-sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,18 +78,6 @@ export function entity(entityUrl: string): EntityObservationSource {
/** Entity type name for the built-in coder entity. */
export const CODING_SESSION_ENTITY_TYPE = `coder`

/**
* Collection event-type strings used by the coder entity's state. The
* coder entity (in `@electric-ax/agents`) declares its three custom
* collections under these names, and any consumer that needs to read
* the same collections back out (the agents-server-ui hook is the
* primary one) imports these constants instead of hard-coding the
* strings — so the entity's contract has a single source of truth.
*/
export const CODING_SESSION_META_COLLECTION_TYPE = `coding_session_meta`
export const CODING_SESSION_CURSOR_COLLECTION_TYPE = `coding_session_cursor`
export const CODING_SESSION_EVENT_COLLECTION_TYPE = `coding_session_event`

export function codingSessionEntityUrl(sessionId: string): string {
return `/${CODING_SESSION_ENTITY_TYPE}/${sessionId}`
}
Expand Down
Loading
Loading