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
5 changes: 5 additions & 0 deletions docs/.changes/20260731-export-all-public-types.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"type": "fixed",
"message": "The package entry point now re-exports every public protocol declaration. 60 were unreachable from it — including the whole customization subsystem (`Customization`, `CustomizationType` and every variant), the whole MCP-server state machine (`McpServerState`, `McpServerStatus`, `McpAuthRequiredReason` and the state variants), 13 action types, and `Icon`, `ChatInteractivity`, `InputRequestResponsePart`, `ToolCallContributor` and `SessionMetadata`.",
"targets": ["typescript"]
}
4 changes: 2 additions & 2 deletions docs/specification/chat-channel.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Multiple chat channels may be active simultaneously. Clients subscribe to each c

## State

Subscribers receive a [`ChatState`](/reference/chat#chatstate) snapshot. `ChatState` denormalizes the [`ChatSummary`](/reference/chat#chatsummary) fields directly onto itself (`resource`, `title`, `status`, `activity`, `modifiedAt`, `origin`, `workingDirectory`) and adds the conversation contents (history of completed turns, the active turn if any, pending messages, outstanding input requests, and the user's in-progress [`draft`](#drafts)). Producers MUST keep the chat's `ChatSummary` in the session catalog consistent with these inlined summary fields — typically by dispatching a matching [`session/chatUpdated`](/reference/session#actions) whenever any summary field on the chat changes. Refer to the [State Model guide](/guide/state-model) for a structural overview.
Subscribers receive a [`ChatState`](/reference/chat#chatstate) snapshot. `ChatState` denormalizes the [`ChatSummary`](/reference/chat#chatsummary) fields directly onto itself (`resource`, `title`, `status`, `activity`, `modifiedAt`, `origin`, `workingDirectories`) and adds the conversation contents (history of completed turns, the active turn if any, pending messages, outstanding input requests, and the user's in-progress [`draft`](#drafts)). Producers MUST keep the chat's `ChatSummary` in the session catalog consistent with these inlined summary fields — typically by dispatching a matching [`session/chatUpdated`](/reference/session#actions) whenever any summary field on the chat changes. Refer to the [State Model guide](/guide/state-model) for a structural overview.

When a client subscribes with `view.turns`, the server MAY expose only a tail of
the most recent completed turns in the initial snapshot. The requested number is
Expand All @@ -39,7 +39,7 @@ Clients MAY periodically sync their local input state into the draft by dispatch

### Per-chat working directory

`ChatState.workingDirectory` (and its mirror on [`ChatSummary`](/reference/chat#chatsummary)) is **optional**. When absent, the chat inherits the session's [`workingDirectory`](/reference/session#sessionsummary). Hosts MAY set a per-chat working directory to give individual chats their own filesystem context — for example, allocating a separate git worktree per chat so multiple chats in the same session can make independent edits that the orchestrating chat later merges back. The session-level `workingDirectory` is then the default/primary location for chats that do not override it.
`ChatState.workingDirectories` (and its mirror on [`ChatSummary`](/reference/chat#chatsummary)) is **optional**. When absent, the chat inherits the session's full [`workingDirectories`](/reference/session#sessionsummary) set; when present it MUST be a subset of that set. Hosts MAY set a per-chat subset to give individual chats their own filesystem context — for example, allocating a separate git worktree per chat so multiple chats in the same session can make independent edits that the orchestrating chat later merges back.

## Relationship to the session channel

Expand Down
4 changes: 2 additions & 2 deletions docs/specification/session-channel.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,14 @@ The producer of the chat's own [`ChatState`](./chat-channel#state) is responsibl

### Chat aggregation

[`SessionSummary`](/reference/session#sessionsummary) carries session-wide identity (`resource`, `provider`, `createdAt`, `workingDirectory`) but several of its mutable fields are aggregates derived from the session's chats. Producers SHOULD apply these rules so clients that only consume the session summary (a session list, for example) still see meaningful state:
[`SessionSummary`](/reference/session#sessionsummary) carries session-wide identity (`resource`, `provider`, `createdAt`, `workingDirectories`) but several of its mutable fields are aggregates derived from the session's chats. Producers SHOULD apply these rules so clients that only consume the session summary (a session list, for example) still see meaningful state:

| Field | Derivation rule |
|---|---|
| `status` | Take the activity bits (`Idle` / `InProgress` / `InputNeeded` / `Error`) from the [`defaultChat`](#defaultchat) when set, else from the most recently modified chat. Promote `InputNeeded` if **any** chat needs input. Promote `Error` if **any** chat is in an error state. The orthogonal `IsRead` / `IsArchived` flags remain session-scoped and pass through unchanged. |
| `activity` | Mirror the activity string of the chat that contributes the activity bits — usually the default chat, but the chat that raised `InputNeeded` / `Error` when a non-default chat wins the promotion. |
| `modifiedAt` | The maximum of every chat's `modifiedAt`. |
| `workingDirectory` | The session-level **default**. Individual chats MAY override via [`ChatSummary.workingDirectory`](/reference/chat#chatsummary); aggregating per-chat overrides up is meaningless and SHOULD NOT be attempted. |
| `workingDirectories` | The session-level set. Individual chats MAY restrict to a subset via [`ChatSummary.workingDirectories`](/reference/chat#chatsummary); aggregating per-chat subsets up is meaningless and SHOULD NOT be attempted. |
| `changes` | Optional roll-up. Producers MAY sum per-chat changeset stats or report the most expensive chat's stats — whichever is cheaper to compute. |

Sessions with a single chat satisfy all of the above trivially (the chat's values pass through). The rules only matter once a session carries multiple chats.
Expand Down
76 changes: 76 additions & 0 deletions scripts/public-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Asserts that every public protocol declaration is reachable from
* `types/index.ts`, which is the published TypeScript client's root export.
*
* Run: npx tsx --test scripts/public-api.test.ts
*/

import { describe, it, before } from 'node:test';
import { strict as assert } from 'node:assert';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Project } from 'ts-morph';

const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');

/** Declarations deliberately withheld from the entry point. */
const INTENTIONALLY_INTERNAL: ReadonlySet<string> = new Set([
'softAssertNever', // reducer-internal exhaustiveness helper
]);

const CHANNEL_MODULE_RE = /types[\\/](common|channels-[a-z-]+)[\\/]/;

describe('public API surface', () => {
let declared: Set<string>;
let exported: Set<string>;

before(() => {
const project = new Project({
tsConfigFilePath: resolve(root, 'types/tsconfig.json'),
skipAddingFilesFromTsConfig: false,
});

declared = new Set<string>();
for (const sourceFile of project.getSourceFiles()) {
const filePath = sourceFile.getFilePath();
if (!CHANNEL_MODULE_RE.test(filePath)) continue;
if (filePath.endsWith('.test.ts')) continue;
for (const name of sourceFile.getExportedDeclarations().keys()) {
declared.add(name);
}
}

exported = new Set(
project.getSourceFileOrThrow(resolve(root, 'types/index.ts'))
.getExportedDeclarations()
.keys(),
);
});

it('finds the protocol declarations (guards the extraction itself)', () => {
assert.ok(declared.size > 100, `found only ${declared.size} declarations — the extraction is probably broken`);
assert.ok(exported.size > 100, `types/index.ts re-exports only ${exported.size} declarations`);
});

it('re-exports every public declaration from types/index.ts', () => {
const missing = [...declared]
.filter(name => !exported.has(name) && !INTENTIONALLY_INTERNAL.has(name))
.sort();

assert.deepEqual(
missing,
[],
`${missing.length} declaration(s) unreachable from types/index.ts, so consumers of the published package cannot name them:\n ${missing.join('\n ')}`,
);
});

it('keeps every intentionally-internal declaration internal', () => {
const leaked = [...INTENTIONALLY_INTERNAL].filter(name => exported.has(name)).sort();

assert.deepEqual(
leaked,
[],
`${leaked.length} internal declaration(s) are now exported — remove them from INTENTIONALLY_INTERNAL if that is intended:\n ${leaked.join('\n ')}`,
);
});
});
Loading
Loading