| author | |
| committer | |
| log | 6e8931d75615ec020e602e97df2694b0541a6272 |
| tree | 447599d2ea45d9c4ad366076cae533a89bda3292 |
| parent | 7bd7f99e6cab940892ff9240549c507bfd066aee |
| signature | Signed by PGP key B5690EEEBB952194 |
12 files changed, 296 insertions(+), 47 deletions(-)
packages/client-runtime/src/authorization/remote.ts+13-13| ... | ... | @@ -11,7 +11,7 @@ import * as Effect from "effect/Effect"; |
| 11 | 11 | import { environmentEndpointUrl } from "../environment/endpoint.ts"; |
| 12 | 12 | import { |
| 13 | 13 | executeEnvironmentHttpRequest, |
| 14 | makeEnvironmentHttpApiClient, | |
| 14 | makeEnvironmentHttpApiGroupClient, | |
| 15 | 15 | type RemoteEnvironmentRequestError, |
| 16 | 16 | } from "../rpc/http.ts"; |
| 17 | 17 | |
| ... | ... | @@ -93,11 +93,11 @@ export const exchangeRemoteDpopAccessToken = Effect.fn( |
| 93 | 93 | readonly dpopProof: string; |
| 94 | 94 | readonly timeoutMs?: number; |
| 95 | 95 | }) { |
| 96 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); | |
| 96 | const client = yield* makeEnvironmentHttpApiGroupClient(input.httpBaseUrl, "auth"); | |
| 97 | 97 | const response = yield* executeEnvironmentHttpRequest( |
| 98 | 98 | environmentEndpointUrl(input.httpBaseUrl, "/oauth/token"), |
| 99 | 99 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, |
| 100 | client.auth.token({ | |
| 100 | client.token({ | |
| 101 | 101 | headers: { dpop: input.dpopProof }, |
| 102 | 102 | payload: { |
| 103 | 103 | grant_type: AuthTokenExchangeGrantType, |
| ... | ... | @@ -121,11 +121,11 @@ export const bootstrapRemoteBearerSession = Effect.fn( |
| 121 | 121 | readonly clientMetadata?: AuthClientPresentationMetadata; |
| 122 | 122 | readonly timeoutMs?: number; |
| 123 | 123 | }) { |
| 124 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); | |
| 124 | const client = yield* makeEnvironmentHttpApiGroupClient(input.httpBaseUrl, "auth"); | |
| 125 | 125 | return yield* executeEnvironmentHttpRequest( |
| 126 | 126 | environmentEndpointUrl(input.httpBaseUrl, "/oauth/token"), |
| 127 | 127 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, |
| 128 | client.auth.token({ | |
| 128 | client.token({ | |
| 129 | 129 | headers: {}, |
| 130 | 130 | payload: { |
| 131 | 131 | grant_type: AuthTokenExchangeGrantType, |
| ... | ... | @@ -146,11 +146,11 @@ export const fetchRemoteSessionState = Effect.fn( |
| 146 | 146 | readonly bearerToken: string; |
| 147 | 147 | readonly timeoutMs?: number; |
| 148 | 148 | }) { |
| 149 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); | |
| 149 | const client = yield* makeEnvironmentHttpApiGroupClient(input.httpBaseUrl, "auth"); | |
| 150 | 150 | return yield* executeEnvironmentHttpRequest( |
| 151 | 151 | environmentEndpointUrl(input.httpBaseUrl, "/api/auth/session"), |
| 152 | 152 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, |
| 153 | client.auth.session({ | |
| 153 | client.session({ | |
| 154 | 154 | headers: { |
| 155 | 155 | authorization: `Bearer ${input.bearerToken}`, |
| 156 | 156 | }, |
| ... | ... | @@ -166,11 +166,11 @@ export const fetchRemoteDpopSessionState = Effect.fn( |
| 166 | 166 | readonly dpopProof: string; |
| 167 | 167 | readonly timeoutMs?: number; |
| 168 | 168 | }) { |
| 169 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); | |
| 169 | const client = yield* makeEnvironmentHttpApiGroupClient(input.httpBaseUrl, "auth"); | |
| 170 | 170 | return yield* executeEnvironmentHttpRequest( |
| 171 | 171 | environmentEndpointUrl(input.httpBaseUrl, "/api/auth/session"), |
| 172 | 172 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, |
| 173 | client.auth.session({ | |
| 173 | client.session({ | |
| 174 | 174 | headers: { |
| 175 | 175 | authorization: `DPoP ${input.accessToken}`, |
| 176 | 176 | dpop: input.dpopProof, |
| ... | ... | @@ -186,11 +186,11 @@ export const issueRemoteWebSocketTicket = Effect.fn( |
| 186 | 186 | readonly bearerToken: string; |
| 187 | 187 | readonly timeoutMs?: number; |
| 188 | 188 | }) { |
| 189 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); | |
| 189 | const client = yield* makeEnvironmentHttpApiGroupClient(input.httpBaseUrl, "auth"); | |
| 190 | 190 | return yield* executeEnvironmentHttpRequest( |
| 191 | 191 | environmentEndpointUrl(input.httpBaseUrl, "/api/auth/websocket-ticket"), |
| 192 | 192 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, |
| 193 | client.auth.webSocketTicket({ | |
| 193 | client.webSocketTicket({ | |
| 194 | 194 | headers: { |
| 195 | 195 | authorization: `Bearer ${input.bearerToken}`, |
| 196 | 196 | }, |
| ... | ... | @@ -206,11 +206,11 @@ export const issueRemoteDpopWebSocketTicket = Effect.fn( |
| 206 | 206 | readonly dpopProof: string; |
| 207 | 207 | readonly timeoutMs?: number; |
| 208 | 208 | }) { |
| 209 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); | |
| 209 | const client = yield* makeEnvironmentHttpApiGroupClient(input.httpBaseUrl, "auth"); | |
| 210 | 210 | return yield* executeEnvironmentHttpRequest( |
| 211 | 211 | environmentEndpointUrl(input.httpBaseUrl, "/api/auth/websocket-ticket"), |
| 212 | 212 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, |
| 213 | client.auth.webSocketTicket({ | |
| 213 | client.webSocketTicket({ | |
| 214 | 214 | headers: { |
| 215 | 215 | authorization: `DPoP ${input.accessToken}`, |
| 216 | 216 | dpop: input.dpopProof, |
packages/client-runtime/src/environment/descriptor.ts+3-3| ... | ... | @@ -1,17 +1,17 @@ |
| 1 | 1 | import * as Effect from "effect/Effect"; |
| 2 | 2 | |
| 3 | 3 | import { environmentEndpointUrl } from "./endpoint.ts"; |
| 4 | import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts"; | |
| 4 | import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiGroupClient } from "../rpc/http.ts"; | |
| 5 | 5 | |
| 6 | 6 | const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000; |
| 7 | 7 | |
| 8 | 8 | export const fetchRemoteEnvironmentDescriptor = Effect.fn( |
| 9 | 9 | "clientRuntime.environment.fetchRemoteEnvironmentDescriptor", |
| 10 | 10 | )(function* (input: { readonly httpBaseUrl: string; readonly timeoutMs?: number }) { |
| 11 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); | |
| 11 | const client = yield* makeEnvironmentHttpApiGroupClient(input.httpBaseUrl, "metadata"); | |
| 12 | 12 | return yield* executeEnvironmentHttpRequest( |
| 13 | 13 | environmentEndpointUrl(input.httpBaseUrl, "/.well-known/t3/environment"), |
| 14 | 14 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, |
| 15 | client.metadata.descriptor(), | |
| 15 | client.descriptor(), | |
| 16 | 16 | ); |
| 17 | 17 | }); |
packages/client-runtime/src/remotePerformance.bench.ts created+165| ... | ... | @@ -0,0 +1,165 @@ |
| 1 | import { | |
| 2 | EnvironmentId, | |
| 3 | EventId, | |
| 4 | MessageId, | |
| 5 | ProjectId, | |
| 6 | ProviderInstanceId, | |
| 7 | ThreadId, | |
| 8 | type OrchestrationEvent, | |
| 9 | type OrchestrationThread, | |
| 10 | } from "@t3tools/contracts"; | |
| 11 | import * as Effect from "effect/Effect"; | |
| 12 | import * as Option from "effect/Option"; | |
| 13 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | |
| 14 | import { bench, describe } from "vite-plus/test"; | |
| 15 | ||
| 16 | import { issueRemoteWebSocketTicket } from "./authorization/remote.ts"; | |
| 17 | import { PrimaryConnectionTarget } from "./connection/model.ts"; | |
| 18 | import { fetchRemoteEnvironmentDescriptor } from "./environment/descriptor.ts"; | |
| 19 | import type { RemoteEnvironmentRequestError } from "./rpc/http.ts"; | |
| 20 | import { fetchEnvironmentThreadSnapshot } from "./state/threadSnapshotHttp.ts"; | |
| 21 | import { applyThreadDetailEvent } from "./state/threadReducer.ts"; | |
| 22 | ||
| 23 | const timestamp = "2026-09-01T00:00:00.000Z"; | |
| 24 | const thread: OrchestrationThread = { | |
| 25 | id: ThreadId.make("thread-1"), | |
| 26 | projectId: ProjectId.make("project-1"), | |
| 27 | title: "Remote thread", | |
| 28 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | |
| 29 | runtimeMode: "full-access", | |
| 30 | interactionMode: "default", | |
| 31 | branch: null, | |
| 32 | worktreePath: null, | |
| 33 | latestTurn: null, | |
| 34 | createdAt: timestamp, | |
| 35 | updatedAt: timestamp, | |
| 36 | archivedAt: null, | |
| 37 | settledOverride: null, | |
| 38 | settledAt: null, | |
| 39 | deletedAt: null, | |
| 40 | pullRequests: [], | |
| 41 | messages: Array.from({ length: 100 }, (_, index) => ({ | |
| 42 | id: MessageId.make(`message-${index}`), | |
| 43 | role: "assistant", | |
| 44 | text: "Message text. ".repeat(40), | |
| 45 | turnId: null, | |
| 46 | streaming: false, | |
| 47 | createdAt: timestamp, | |
| 48 | updatedAt: timestamp, | |
| 49 | })), | |
| 50 | proposedPlans: [], | |
| 51 | activities: [], | |
| 52 | checkpoints: [], | |
| 53 | session: null, | |
| 54 | }; | |
| 55 | const target = new PrimaryConnectionTarget({ | |
| 56 | environmentId: EnvironmentId.make("remote-1"), | |
| 57 | label: "Remote", | |
| 58 | httpBaseUrl: "https://remote.example.test", | |
| 59 | wsBaseUrl: "wss://remote.example.test/ws", | |
| 60 | }); | |
| 61 | const responses = { | |
| 62 | "/.well-known/t3/environment": { | |
| 63 | environmentId: target.environmentId, | |
| 64 | label: target.label, | |
| 65 | platform: { os: "linux", arch: "x64" }, | |
| 66 | serverVersion: "0.0.0-test", | |
| 67 | capabilities: { repositoryIdentity: true }, | |
| 68 | }, | |
| 69 | "/api/auth/websocket-ticket": { ticket: "test-ticket", expiresAt: timestamp }, | |
| 70 | "/api/orchestration/threads/thread-1": { snapshotSequence: 1, thread }, | |
| 71 | }; | |
| 72 | const httpClient = HttpClient.make((request) => | |
| 73 | Effect.sync(() => { | |
| 74 | const path = new URL(request.url).pathname as keyof typeof responses; | |
| 75 | return HttpClientResponse.fromWeb(request, Response.json(responses[path])); | |
| 76 | }), | |
| 77 | ); | |
| 78 | const requests: Record< | |
| 79 | string, | |
| 80 | Effect.Effect<unknown, RemoteEnvironmentRequestError, HttpClient.HttpClient> | |
| 81 | > = { | |
| 82 | "read remote connection descriptor": fetchRemoteEnvironmentDescriptor({ | |
| 83 | httpBaseUrl: target.httpBaseUrl, | |
| 84 | }), | |
| 85 | "issue remote WebSocket ticket": issueRemoteWebSocketTicket({ | |
| 86 | httpBaseUrl: target.httpBaseUrl, | |
| 87 | bearerToken: "test-token", | |
| 88 | }), | |
| 89 | "load remote snapshot with 100 messages": fetchEnvironmentThreadSnapshot({ | |
| 90 | prepared: { | |
| 91 | environmentId: target.environmentId, | |
| 92 | label: target.label, | |
| 93 | httpBaseUrl: target.httpBaseUrl, | |
| 94 | socketUrl: target.wsBaseUrl, | |
| 95 | httpAuthorization: null, | |
| 96 | target, | |
| 97 | }, | |
| 98 | threadId: thread.id, | |
| 99 | signer: Option.none(), | |
| 100 | }), | |
| 101 | }; | |
| 102 | ||
| 103 | describe("remote HTTP processing with an in-memory transport", () => { | |
| 104 | for (const [name, request] of Object.entries(requests)) { | |
| 105 | bench( | |
| 106 | name, | |
| 107 | async () => { | |
| 108 | await Effect.runPromise( | |
| 109 | request.pipe(Effect.provideService(HttpClient.HttpClient, httpClient)), | |
| 110 | ); | |
| 111 | }, | |
| 112 | { warmupTime: 1_000, time: 1_500 }, | |
| 113 | ); | |
| 114 | } | |
| 115 | }); | |
| 116 | ||
| 117 | const delta: OrchestrationEvent = { | |
| 118 | eventId: EventId.make("delta"), | |
| 119 | sequence: 2, | |
| 120 | aggregateKind: "thread", | |
| 121 | aggregateId: thread.id, | |
| 122 | occurredAt: timestamp, | |
| 123 | commandId: null, | |
| 124 | causationEventId: null, | |
| 125 | correlationId: null, | |
| 126 | metadata: {}, | |
| 127 | type: "thread.message-sent", | |
| 128 | payload: { | |
| 129 | threadId: thread.id, | |
| 130 | messageId: MessageId.make("message-99"), | |
| 131 | role: "assistant", | |
| 132 | text: " next", | |
| 133 | turnId: null, | |
| 134 | streaming: true, | |
| 135 | createdAt: timestamp, | |
| 136 | updatedAt: timestamp, | |
| 137 | }, | |
| 138 | }; | |
| 139 | ||
| 140 | describe("remote message replay", () => { | |
| 141 | for (const count of [100, 1_000]) { | |
| 142 | const loaded = { | |
| 143 | ...thread, | |
| 144 | messages: Array.from({ length: count }, (_, index) => ({ | |
| 145 | ...thread.messages[0]!, | |
| 146 | id: MessageId.make(`message-${index}`), | |
| 147 | })), | |
| 148 | }; | |
| 149 | const event = { | |
| 150 | ...delta, | |
| 151 | payload: { ...delta.payload, messageId: loaded.messages.at(-1)!.id }, | |
| 152 | }; | |
| 153 | bench( | |
| 154 | `apply 200 text deltas to ${count} loaded messages`, | |
| 155 | () => { | |
| 156 | let current: OrchestrationThread = loaded; | |
| 157 | for (let index = 0; index < 200; index += 1) { | |
| 158 | const result = applyThreadDetailEvent(current, event); | |
| 159 | if (result.kind === "updated") current = result.thread; | |
| 160 | } | |
| 161 | }, | |
| 162 | { warmupTime: 1_000, time: 1_500 }, | |
| 163 | ); | |
| 164 | } | |
| 165 | }); |
packages/client-runtime/src/rpc/http.ts+14| ... | ... | @@ -99,6 +99,20 @@ export const makeEnvironmentHttpApiClient = (httpBaseUrl: string) => |
| 99 | 99 | baseUrl: remoteApiBaseUrl(httpBaseUrl), |
| 100 | 100 | }); |
| 101 | 101 | |
| 102 | export const makeEnvironmentHttpApiGroupClient = < | |
| 103 | Group extends keyof typeof EnvironmentHttpApi.groups, | |
| 104 | >( | |
| 105 | httpBaseUrl: string, | |
| 106 | group: Group, | |
| 107 | ) => | |
| 108 | Effect.flatMap(HttpClient.HttpClient, (httpClient) => | |
| 109 | HttpApiClient.group(EnvironmentHttpApi, { | |
| 110 | httpClient, | |
| 111 | group, | |
| 112 | baseUrl: remoteApiBaseUrl(httpBaseUrl), | |
| 113 | }), | |
| 114 | ); | |
| 115 | ||
| 102 | 116 | /** Contract-derived request URLs for authentication proofs, tracing, and structured errors. */ |
| 103 | 117 | export const makeEnvironmentHttpApiUrlBuilder = (httpBaseUrl: string) => |
| 104 | 118 | HttpApiClient.urlBuilder(EnvironmentHttpApi, { |
packages/client-runtime/src/state/environmentHttpAuth.test.ts+11| ... | ... | @@ -215,6 +215,17 @@ const LOADERS: ReadonlyArray<{ |
| 215 | 215 | ]; |
| 216 | 216 | |
| 217 | 217 | describe("authenticated environment HTTP requests", () => { |
| 218 | it.effect.each(LOADERS)("rejects an invalid $name response", (loader) => | |
| 219 | Effect.gen(function* () { | |
| 220 | const harness = makeHarness(() => Response.json({})); | |
| 221 | const result = yield* loader | |
| 222 | .load(harness.input) | |
| 223 | .pipe(Effect.provide(harness.httpLayer), Effect.asVoid, Effect.flip); | |
| 224 | expect(result._tag).toBe("RemoteEnvironmentAuthInvalidJsonError"); | |
| 225 | expect(harness.calls).toHaveLength(1); | |
| 226 | }), | |
| 227 | ); | |
| 228 | ||
| 218 | 229 | it.effect.each(LOADERS)("uses current relay authorization and endpoint for $name", (loader) => |
| 219 | 230 | Effect.gen(function* () { |
| 220 | 231 | const harness = makeHarness(() => Response.json(loader.response)); |
packages/client-runtime/src/state/environmentHttpAuth.ts+16-6| ... | ... | @@ -1,14 +1,14 @@ |
| 1 | 1 | import * as Effect from "effect/Effect"; |
| 2 | 2 | import * as Option from "effect/Option"; |
| 3 | 3 | import * as Result from "effect/Result"; |
| 4 | import { FetchHttpClient, type HttpClient, type HttpMethod } from "effect/unstable/http"; | |
| 4 | import { FetchHttpClient, type HttpMethod } from "effect/unstable/http"; | |
| 5 | 5 | |
| 6 | 6 | import type { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; |
| 7 | 7 | import type { PreparedConnection, PreparedHttpAuthorization } from "../connection/model.ts"; |
| 8 | 8 | import type { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; |
| 9 | 9 | import { |
| 10 | 10 | executeEnvironmentHttpRequest, |
| 11 | makeEnvironmentHttpApiClient, | |
| 11 | makeEnvironmentHttpApiGroupClient, | |
| 12 | 12 | RemoteEnvironmentAuthFetchError, |
| 13 | 13 | RemoteEnvironmentAuthTimeoutError, |
| 14 | 14 | type RemoteEnvironmentRequestError, |
| ... | ... | @@ -86,20 +86,30 @@ const buildEnvironmentAuthHeaders = ( |
| 86 | 86 | */ |
| 87 | 87 | export const executeAuthenticatedEnvironmentHttpRequest = Effect.fn( |
| 88 | 88 | "clientRuntime.state.executeAuthenticatedEnvironmentHttpRequest", |
| 89 | )(function* <A, E, R>(input: { | |
| 89 | )(function* < | |
| 90 | Group extends Parameters<typeof makeEnvironmentHttpApiGroupClient>[1], | |
| 91 | A, | |
| 92 | E, | |
| 93 | R, | |
| 94 | >(input: { | |
| 90 | 95 | readonly prepared: PreparedConnection; |
| 91 | 96 | readonly signer: Option.Option<ManagedRelayDpopSigner["Service"]>; |
| 92 | 97 | readonly remoteAuthorization?: Option.Option<RemoteEnvironmentAuthorization["Service"]>; |
| 93 | 98 | readonly method: HttpMethod.HttpMethod; |
| 94 | 99 | readonly url: (httpBaseUrl: string) => string; |
| 95 | 100 | readonly timeoutMs: number; |
| 101 | readonly group: Group; | |
| 96 | 102 | readonly request: (input: { |
| 97 | readonly client: Effect.Success<ReturnType<typeof makeEnvironmentHttpApiClient>>; | |
| 103 | readonly client: Effect.Success<ReturnType<typeof makeEnvironmentHttpApiGroupClient<Group>>>; | |
| 98 | 104 | readonly headers: EnvironmentHttpAuthHeaders; |
| 99 | 105 | }) => Effect.Effect<A, E, R>; |
| 100 | 106 | /** Some endpoints report rejected credentials in a successful response. */ |
| 101 | 107 | readonly isUnauthorizedResponse?: (response: NoInfer<A>) => boolean; |
| 102 | }): Effect.fn.Return<A, RemoteEnvironmentRequestError, HttpClient.HttpClient | R> { | |
| 108 | }): Effect.fn.Return< | |
| 109 | A, | |
| 110 | RemoteEnvironmentRequestError, | |
| 111 | Effect.Services<ReturnType<typeof makeEnvironmentHttpApiGroupClient<Group>>> | R | |
| 112 | > { | |
| 103 | 113 | let httpBaseUrl = input.prepared.httpBaseUrl; |
| 104 | 114 | return yield* Effect.gen(function* () { |
| 105 | 115 | let rejectedAccessToken: string | undefined; |
| ... | ... | @@ -132,7 +142,7 @@ export const executeAuthenticatedEnvironmentHttpRequest = Effect.fn( |
| 132 | 142 | } |
| 133 | 143 | |
| 134 | 144 | const requestUrl = input.url(httpBaseUrl); |
| 135 | const client = yield* makeEnvironmentHttpApiClient(httpBaseUrl); | |
| 145 | const client = yield* makeEnvironmentHttpApiGroupClient(httpBaseUrl, input.group); | |
| 136 | 146 | const headers = yield* buildEnvironmentAuthHeaders( |
| 137 | 147 | authorization, |
| 138 | 148 | input.method, |
packages/client-runtime/src/state/pullRequestDiffHttp.ts+2-1| ... | ... | @@ -50,10 +50,11 @@ export const fetchEnvironmentPullRequestDiff = Effect.fn( |
| 50 | 50 | }) { |
| 51 | 51 | return yield* executeAuthenticatedEnvironmentHttpRequest({ |
| 52 | 52 | ...input, |
| 53 | group: "pullRequests", | |
| 53 | 54 | method: "POST", |
| 54 | 55 | url: (httpBaseUrl) => makeEnvironmentHttpApiUrlBuilder(httpBaseUrl).pullRequests.diff(), |
| 55 | 56 | timeoutMs: input.timeoutMs ?? DEFAULT_PULL_REQUEST_DIFF_TIMEOUT_MS, |
| 56 | request: ({ client, headers }) => client.pullRequests.diff({ payload: input.diff, headers }), | |
| 57 | request: ({ client, headers }) => client.diff({ payload: input.diff, headers }), | |
| 57 | 58 | }).pipe( |
| 58 | 59 | Effect.mapError((error) => |
| 59 | 60 | error._tag === "EnvironmentAuthInvalidError" && error.reason === "invalid_credential" |
packages/client-runtime/src/state/session.ts+2-1| ... | ... | @@ -49,10 +49,11 @@ export const fetchEnvironmentSessionState = Effect.fn( |
| 49 | 49 | }) { |
| 50 | 50 | return yield* executeAuthenticatedEnvironmentHttpRequest({ |
| 51 | 51 | ...input, |
| 52 | group: "auth", | |
| 52 | 53 | method: "GET", |
| 53 | 54 | url: (httpBaseUrl) => environmentEndpointUrl(httpBaseUrl, "/api/auth/session"), |
| 54 | 55 | timeoutMs: input.timeoutMs ?? DEFAULT_SESSION_STATE_TIMEOUT_MS, |
| 55 | request: ({ client, headers }) => client.auth.session({ headers }), | |
| 56 | request: ({ client, headers }) => client.session({ headers }), | |
| 56 | 57 | // This endpoint returns 200 with authenticated:false for expired credentials. |
| 57 | 58 | isUnauthorizedResponse: (response) => !response.authenticated, |
| 58 | 59 | }); |
packages/client-runtime/src/state/shellSnapshotHttp.ts+2-1| ... | ... | @@ -32,10 +32,11 @@ export const fetchEnvironmentShellSnapshot = Effect.fn( |
| 32 | 32 | }) { |
| 33 | 33 | return yield* executeAuthenticatedEnvironmentHttpRequest({ |
| 34 | 34 | ...input, |
| 35 | group: "orchestration", | |
| 35 | 36 | method: "GET", |
| 36 | 37 | url: (httpBaseUrl) => environmentEndpointUrl(httpBaseUrl, "/api/orchestration/shell"), |
| 37 | 38 | timeoutMs: input.timeoutMs ?? DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS, |
| 38 | request: ({ client, headers }) => client.orchestration.shellSnapshot({ headers }), | |
| 39 | request: ({ client, headers }) => client.shellSnapshot({ headers }), | |
| 39 | 40 | }); |
| 40 | 41 | }); |
| 41 | 42 |
packages/client-runtime/src/state/threadReducer.test.ts+48| ... | ... | @@ -526,6 +526,54 @@ describe("applyThreadDetailEvent", () => { |
| 526 | 526 | }); |
| 527 | 527 | |
| 528 | 528 | describe("thread.message-sent", () => { |
| 529 | it.each([ | |
| 530 | ["first", ["first+", "middle", "last"]], | |
| 531 | ["middle", ["first", "middle+", "last"]], | |
| 532 | ["last", ["first", "middle", "last+"]], | |
| 533 | ["new", ["first", "middle", "last", "+"]], | |
| 534 | ] as const)("applies a delta to %s without changing other messages", (id, texts) => { | |
| 535 | const messages = Object.freeze( | |
| 536 | ["first", "middle", "last"].map((name) => | |
| 537 | Object.freeze({ | |
| 538 | id: MessageId.make(name), | |
| 539 | role: "assistant" as const, | |
| 540 | text: name, | |
| 541 | turnId: null, | |
| 542 | streaming: false, | |
| 543 | createdAt: baseThread.createdAt, | |
| 544 | updatedAt: baseThread.updatedAt, | |
| 545 | }), | |
| 546 | ), | |
| 547 | ); | |
| 548 | const result = applyThreadDetailEvent( | |
| 549 | { ...baseThread, messages }, | |
| 550 | { | |
| 551 | ...baseEventFields, | |
| 552 | sequence: 6, | |
| 553 | occurredAt: baseThread.updatedAt, | |
| 554 | aggregateKind: "thread", | |
| 555 | aggregateId: baseThread.id, | |
| 556 | type: "thread.message-sent", | |
| 557 | payload: { | |
| 558 | threadId: baseThread.id, | |
| 559 | messageId: MessageId.make(id), | |
| 560 | role: "assistant", | |
| 561 | text: "+", | |
| 562 | turnId: null, | |
| 563 | streaming: true, | |
| 564 | createdAt: baseThread.createdAt, | |
| 565 | updatedAt: baseThread.updatedAt, | |
| 566 | }, | |
| 567 | }, | |
| 568 | ); | |
| 569 | expect(result.kind).toBe("updated"); | |
| 570 | if (result.kind !== "updated") return; | |
| 571 | expect(result.thread.messages.map((message) => message.text)).toEqual(texts); | |
| 572 | for (const [index, message] of messages.entries()) { | |
| 573 | if (message.id !== id) expect(result.thread.messages[index]).toBe(message); | |
| 574 | } | |
| 575 | }); | |
| 576 | ||
| 529 | 577 | it("appends a new message", () => { |
| 530 | 578 | const result = applyThreadDetailEvent(baseThread, { |
| 531 | 579 | ...baseEventFields, |
packages/client-runtime/src/state/threadReducer.ts+18-21| ... | ... | @@ -386,27 +386,24 @@ export function applyThreadDetailEvent( |
| 386 | 386 | updatedAt: event.payload.updatedAt, |
| 387 | 387 | }; |
| 388 | 388 | |
| 389 | const existingMessage = thread.messages.find((entry) => entry.id === message.id); | |
| 390 | const messages = existingMessage | |
| 391 | ? Arr.map(thread.messages, (entry) => | |
| 392 | entry.id !== message.id | |
| 393 | ? entry | |
| 394 | : { | |
| 395 | ...entry, | |
| 396 | text: message.streaming | |
| 397 | ? `${entry.text}${message.text}` | |
| 398 | : message.text.length > 0 | |
| 399 | ? message.text | |
| 400 | : entry.text, | |
| 401 | streaming: message.streaming, | |
| 402 | ...(message.turnId !== undefined ? { turnId: message.turnId } : {}), | |
| 403 | ...(message.streaming ? {} : { updatedAt: message.updatedAt }), | |
| 404 | ...(message.attachments !== undefined | |
| 405 | ? { attachments: message.attachments } | |
| 406 | : {}), | |
| 407 | }, | |
| 408 | ) | |
| 409 | : Arr.append(thread.messages, message); | |
| 389 | let found = false; | |
| 390 | const messages = thread.messages.map((entry) => { | |
| 391 | if (entry.id !== message.id) return entry; | |
| 392 | found = true; | |
| 393 | return { | |
| 394 | ...entry, | |
| 395 | text: message.streaming | |
| 396 | ? `${entry.text}${message.text}` | |
| 397 | : message.text.length > 0 | |
| 398 | ? message.text | |
| 399 | : entry.text, | |
| 400 | streaming: message.streaming, | |
| 401 | ...(message.turnId !== undefined ? { turnId: message.turnId } : {}), | |
| 402 | ...(message.streaming ? {} : { updatedAt: message.updatedAt }), | |
| 403 | ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), | |
| 404 | }; | |
| 405 | }); | |
| 406 | if (!found) messages.push(message); | |
| 410 | 407 | // Update latestTurn for assistant messages bound to a turn. A completed |
| 411 | 408 | // assistant message only settles the turn once the session is no longer |
| 412 | 409 | // running it — providers may emit several assistant messages per turn |
packages/client-runtime/src/state/threadSnapshotHttp.ts+2-1| ... | ... | @@ -45,12 +45,13 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn( |
| 45 | 45 | }) { |
| 46 | 46 | return yield* executeAuthenticatedEnvironmentHttpRequest({ |
| 47 | 47 | ...input, |
| 48 | group: "orchestration", | |
| 48 | 49 | method: "GET", |
| 49 | 50 | url: (httpBaseUrl) => |
| 50 | 51 | environmentEndpointUrl(httpBaseUrl, `/api/orchestration/threads/${input.threadId}`), |
| 51 | 52 | timeoutMs: input.timeoutMs ?? DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS, |
| 52 | 53 | request: ({ client, headers }) => |
| 53 | client.orchestration.threadSnapshot({ | |
| 54 | client.threadSnapshot({ | |
| 54 | 55 | params: { threadId: input.threadId }, |
| 55 | 56 | payload: { |
| 56 | 57 | ...(input.window !== undefined ? { turnLimit: input.window.turnLimit } : {}), |