Skip to content

Commit 472f26d

Browse files
committed
feat(sdk): resume durable agent sessions
Persist explicit SDK sessions across managed Host restarts and restore them through the existing Agent Runtime. Keep transient query behavior unchanged and preserve same-session writer exclusion.
1 parent e0a9a1b commit 472f26d

23 files changed

Lines changed: 1185 additions & 222 deletions

File tree

sdk/typescript/README.md

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ The slice validates the intended public object model:
99
- one application-level `AgentClient` owns one managed native
1010
`bitfun-sdk-host` process and one Host connection;
1111
- `client.query()` uses a Host-managed transient Session;
12-
- `client.sessions.create()` creates an explicit Session whose Turns reuse the
13-
same connection and existing Agent Runtime owner;
12+
- `client.sessions.create()` creates a durable Session whose Turns reuse the
13+
same connection, while `client.sessions.resume(id)` attaches it to a later
14+
Host process;
1415
- `Query` is an ordered async stream with idempotent cancellation, cached final
1516
`Result`, and explicit close semantics;
1617
- the same stream reports safe Tool lifecycle facts and permission requests;
@@ -22,6 +23,12 @@ kill-on-close Job Object, while Unix managed Hosts run in an isolated process
2223
group. A cleanup result whose outcome is unknown makes the connection
2324
unusable and triggers Host reclamation.
2425

26+
Durable Sessions are persisted by the existing Agent Runtime. Closing a
27+
Session or its client unloads it without deleting its history, so a later
28+
client using the same workspace can resume it by ID. Existing OS-level Session
29+
locks reject a second writer while another Host process owns that same Session;
30+
different Sessions remain independent.
31+
2532
It does not start the CLI or the Node/Bun Plugin Host, and it does not implement
2633
another Agent Runtime. The managed native Host adapts this package to the
2734
existing `agent-runtime::sdk` API.
@@ -75,6 +82,23 @@ for await (const item of query) {
7582
const result = await query.result();
7683
```
7784

85+
Use an explicit Session when the application needs continuity across client or
86+
Host restarts. Here `options` is the same trusted `AgentClientOptions` value
87+
shown above:
88+
89+
```typescript
90+
const firstClient = await AgentClient.start(options);
91+
const session = await firstClient.sessions.create({ sessionName: "review" });
92+
const sessionId = session.id;
93+
await (await session.startTurn({ prompt: "Inspect the current changes" })).result();
94+
await firstClient.close(); // unloads the Session and exits its managed Host
95+
96+
const nextClient = await AgentClient.start(options);
97+
const resumed = await nextClient.sessions.resume(sessionId);
98+
await (await resumed.startTurn({ prompt: "Now summarize the risks" })).result();
99+
await nextClient.close();
100+
```
101+
78102
An explicit absolute `hostPath` remains available as a development override.
79103
The SDK never searches `PATH` or an environment variable for the Host.
80104

@@ -88,8 +112,8 @@ separately. This PR does not publish the package. A future registry release
88112
still needs platform packages, signing, and release verification.
89113

90114
Browser and mobile runtimes cannot launch the local native Host. Custom
91-
functions, general user-input callbacks, structured output, usage, Session
92-
resume, Python support, platform package publication, signing, and downloads
115+
functions, general user-input callbacks, structured output, usage, Python
116+
support, platform package publication, signing, and downloads
93117
remain deferred.
94118

95119
## Development

sdk/typescript/scripts/generate-wire.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ const requiredTypes = [
6161
"SessionCloseResult",
6262
"SessionCreateParams",
6363
"SessionCreateResult",
64+
"SessionResumeParams",
6465
"ShutdownResult",
6566
"TemporaryModelConfig",
6667
"TemporaryModelProvider",

sdk/typescript/scripts/generated-wire-runtime.test.mjs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,13 @@ test("Rust wire export produces executable validators for every type", async ()
4343
}
4444

4545
const initializeResult = {
46-
protocolVersion: 3,
46+
protocolVersion: 4,
4747
runtimeVersion: "0.1.0",
4848
stability: "not_delivered",
4949
capabilities: {
5050
sessionCreate: true,
51-
sessionCreateLifetime: "connection",
51+
sessionCreateLifetime: "durable",
52+
sessionResume: true,
5253
query: true,
5354
queryCancel: true,
5455
sessionClose: true,

sdk/typescript/src/internal/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { JsonRpcConnection } from "./json-rpc.js";
44
import type { HostTransport } from "./transport.js";
55
import type { InitializeParams, InitializeResult } from "./wire/index.js";
66

7-
const PROTOCOL_VERSION = 3;
7+
const PROTOCOL_VERSION = 4;
88
const DEFAULT_INITIALIZE_TIMEOUT_MS = 30_000;
99

1010
export async function createAgentClient(

sdk/typescript/src/internal/wire-validation.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export function validateResponseResult<T>(method: string, value: unknown): T {
3636
case "initialize":
3737
return validateInitializeResult(value) as T;
3838
case "session/create":
39+
case "session/resume":
3940
return validateSessionCreateResult(value) as T;
4041
case "query/start":
4142
return validateQueryStartResult(value) as T;

sdk/typescript/src/session.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
SessionCloseParams,
55
SessionCreateParams,
66
SessionCreateResult,
7+
SessionResumeParams,
78
} from "./internal/wire/index.js";
89
import type { JsonRpcConnection } from "./internal/json-rpc.js";
910
import { withTimeout } from "./internal/deadline.js";
@@ -70,6 +71,21 @@ export class Sessions {
7071
this.#onSession(session);
7172
return session;
7273
}
74+
75+
async resume(sessionId: string): Promise<Session> {
76+
this.#ensureClientOpen();
77+
if (sessionId.trim().length === 0) {
78+
throw new Error("sessionId must not be empty");
79+
}
80+
const params: SessionResumeParams = { sessionId };
81+
const resumed = await this.#connection.request<SessionCreateResult>(
82+
"session/resume",
83+
params,
84+
);
85+
const session = Session.create(this.#connection, resumed, this.#onQuery);
86+
this.#onSession(session);
87+
return session;
88+
}
7389
}
7490

7591
export class Session {

sdk/typescript/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export interface SdkErrorDetails {
8484
recovery?: RecoveryAction;
8585
}
8686

87-
export type SessionLifetime = "connection";
87+
export type SessionLifetime = "connection" | "durable";
8888

8989
export interface QueryInput {
9090
prompt: string;

sdk/typescript/test/client.test.ts

Lines changed: 93 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ test("a Query streams tool and permission events before the terminal Result", as
5555
assert.ok(client instanceof AgentClient);
5656
assert.equal(initializeRequests.length, 1);
5757
assert.deepEqual(initializeRequests[0], {
58-
protocolVersion: 3,
58+
protocolVersion: 4,
5959
clientInfo: { name: "@bitfun/agent-sdk", version: "0.0.0" },
6060
capabilities: {
6161
serverNotifications: true,
@@ -187,6 +187,7 @@ test("an explicit Session starts Turns on the existing client connection", async
187187
} as SessionCreateInput);
188188
assert.equal(session.id, "session-explicit");
189189
assert.equal(session.agent, "agentic");
190+
assert.equal(session.lifetime, "durable");
190191

191192
const query = await session.startTurn({ prompt: "continue" });
192193
assert.equal((await query.result()).outputText, "continued");
@@ -203,6 +204,37 @@ test("an explicit Session starts Turns on the existing client connection", async
203204
]);
204205
});
205206

207+
test("a durable Session resumes on a new client connection", async () => {
208+
const clientToHost = new PassThrough();
209+
const hostToClient = new PassThrough();
210+
const methods: string[] = [];
211+
const host = runResumeFixtureHost(clientToHost, hostToClient, methods);
212+
const client = await createAgentClient(
213+
{
214+
readable: hostToClient,
215+
writable: clientToHost,
216+
close: async () => {
217+
clientToHost.end();
218+
await host;
219+
},
220+
},
221+
clientOptions,
222+
);
223+
224+
const session = await client.sessions.resume("session-persisted");
225+
assert.equal(session.id, "session-persisted");
226+
assert.equal(session.lifetime, "durable");
227+
await session.close();
228+
await client.close();
229+
230+
assert.deepEqual(methods, [
231+
"initialize",
232+
"session/resume",
233+
"session/close",
234+
"shutdown",
235+
]);
236+
});
237+
206238
test("Query cancel and close are idempotent and the Host Result remains authoritative", async () => {
207239
const clientToHost = new PassThrough();
208240
const hostToClient = new PassThrough();
@@ -530,12 +562,13 @@ async function runFixtureHost(
530562
jsonrpc: "2.0",
531563
id: request.id,
532564
result: {
533-
protocolVersion: 3,
565+
protocolVersion: 4,
534566
runtimeVersion: "0.2.17",
535567
stability: "not_delivered",
536568
capabilities: {
537569
sessionCreate: true,
538-
sessionCreateLifetime: "connection",
570+
sessionCreateLifetime: "durable",
571+
sessionResume: true,
539572
query: true,
540573
queryCancel: true,
541574
sessionClose: true,
@@ -725,7 +758,7 @@ async function runSessionFixtureHost(
725758
sessionId: "session-explicit",
726759
sessionName: "Explicit",
727760
agent: "agentic",
728-
lifetime: "connection",
761+
lifetime: "durable",
729762
},
730763
});
731764
continue;
@@ -786,6 +819,59 @@ async function runSessionFixtureHost(
786819
}
787820
}
788821

822+
async function runResumeFixtureHost(
823+
requests: PassThrough,
824+
responses: PassThrough,
825+
methods: string[],
826+
): Promise<void> {
827+
const lines = createInterface({ input: requests, crlfDelay: Infinity });
828+
for await (const line of lines) {
829+
const request = JSON.parse(line) as {
830+
id: number;
831+
method: string;
832+
params: Record<string, unknown>;
833+
};
834+
methods.push(request.method);
835+
if (request.method === "initialize") {
836+
write(responses, initializeResponse(request.id));
837+
continue;
838+
}
839+
if (request.method === "session/resume") {
840+
assert.deepEqual(request.params, { sessionId: "session-persisted" });
841+
write(responses, {
842+
jsonrpc: "2.0",
843+
id: request.id,
844+
result: {
845+
sessionId: "session-persisted",
846+
sessionName: "Persisted",
847+
agent: "agentic",
848+
lifetime: "durable",
849+
workspacePath: "D:/workspace/project",
850+
},
851+
});
852+
continue;
853+
}
854+
if (request.method === "session/close") {
855+
write(responses, {
856+
jsonrpc: "2.0",
857+
id: request.id,
858+
result: { sessionId: "session-persisted", unloaded: true },
859+
});
860+
continue;
861+
}
862+
if (request.method === "shutdown") {
863+
write(responses, {
864+
jsonrpc: "2.0",
865+
id: request.id,
866+
result: { accepted: true },
867+
});
868+
responses.end();
869+
return;
870+
}
871+
throw new Error(`Unexpected fixture method: ${request.method}`);
872+
}
873+
}
874+
789875
async function runCancelFixtureHost(
790876
requests: PassThrough,
791877
responses: PassThrough,
@@ -1227,12 +1313,13 @@ function initializeResponse(id: number): unknown {
12271313
jsonrpc: "2.0",
12281314
id,
12291315
result: {
1230-
protocolVersion: 3,
1316+
protocolVersion: 4,
12311317
runtimeVersion: "0.2.17",
12321318
stability: "not_delivered",
12331319
capabilities: {
12341320
sessionCreate: true,
1235-
sessionCreateLifetime: "connection",
1321+
sessionCreateLifetime: "durable",
1322+
sessionResume: true,
12361323
query: true,
12371324
queryCancel: true,
12381325
sessionClose: true,

sdk/typescript/test/fixtures/host.mjs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ for await (const line of lines) {
55
const request = JSON.parse(line);
66
if (request.method === "initialize") {
77
if (
8-
request.params?.protocolVersion !== 3 ||
8+
request.params?.protocolVersion !== 4 ||
99
request.params?.model?.apiKey !== "fixture-secret"
1010
) {
1111
throw new Error("Invalid initialize request");
@@ -14,12 +14,13 @@ for await (const line of lines) {
1414
jsonrpc: "2.0",
1515
id: request.id,
1616
result: {
17-
protocolVersion: 3,
17+
protocolVersion: 4,
1818
runtimeVersion: "fixture",
1919
stability: "not_delivered",
2020
capabilities: {
2121
sessionCreate: true,
22-
sessionCreateLifetime: "connection",
22+
sessionCreateLifetime: "durable",
23+
sessionResume: true,
2324
query: true,
2425
queryCancel: true,
2526
sessionClose: true,

sdk/typescript/test/lifecycle-timeouts.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ test("Session.close aborts an unresponsive Host after its cleanup deadline", asy
130130
sessionId: "session-timeout",
131131
sessionName: "timeout",
132132
agent: "agentic",
133-
lifetime: "connection",
133+
lifetime: "durable",
134134
};
135135
const createSession = Session.create as unknown as (
136136
owner: JsonRpcConnection,

0 commit comments

Comments
 (0)