diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 35032fbf6f..8b3cf71006 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -257,6 +257,12 @@ jobs: # Separate from `signed`: the binaries can be signed while the installer is not, and the # notes must not round one up into the other. installer: ${{ steps.installer-result.outputs.installer }} + # WHICH method signed, not just whether something did. The notes used to name Azure + # Trusted Signing unconditionally while this repository signs with the organization + # Authenticode certificate, so every published release claimed a method it had not + # used. `signed` cannot carry that -- it is a boolean about success -- and a reader has + # no way to check, which is exactly the failure the comment above warns about. + method: ${{ steps.config.outputs.method }} env: AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} @@ -676,6 +682,7 @@ jobs: UPSTREAM: ${{ needs.version.outputs.upstream }} WINDOWS_SIGNED: ${{ needs.sign-windows.outputs.signed }} WINDOWS_INSTALLER: ${{ needs.sign-windows.outputs.installer }} + WINDOWS_SIGNING_METHOD: ${{ needs.sign-windows.outputs.method }} run: | set -euo pipefail # An annotated tag records a tagger, and a bare runner has no git identity, so @@ -721,7 +728,21 @@ jobs: echo fi if [ "$WINDOWS_SIGNED" = "true" ]; then - echo "Windows binaries are signed through Azure Trusted Signing." + # Named from what the run actually did. An unrecognised value is described + # without naming a method rather than guessed at: a wrong provenance claim is + # worse than a vaguer true one, because a reader cannot tell it is wrong. + case "${WINDOWS_SIGNING_METHOD:-}" in + azure) + echo "Windows binaries are signed through Azure Trusted Signing." + ;; + pfx) + echo "Windows binaries are signed with the Redrob Authenticode certificate." + ;; + *) + echo "Windows binaries are signed. Verify the signature with \`signtool verify /pa\`" + echo "or against the provenance attestation below." + ;; + esac else echo "**Windows binaries in this release are NOT signed**, so Windows will show a" echo "SmartScreen warning on first run. Verify the archive against \`SHA256SUMS\`" diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index 8f31885f9b..a0e209872b 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -266,152 +266,176 @@ const Endpoint6_1 = (raw: RawClient["server.provider"]) => (input: Endpoint6_1In const adaptGroup6 = (raw: RawClient["server.provider"]) => ({ list: Endpoint6_0(raw), get: Endpoint6_1(raw) }) -type Endpoint7_0Request = Parameters[0] -type Endpoint7_0Input = { readonly location?: Endpoint7_0Request["query"]["location"] } -const Endpoint7_0 = (raw: RawClient["server.integration"]) => (input?: Endpoint7_0Input) => - raw["integration.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) +type Endpoint7_0Request = Parameters[0] +type Endpoint7_0Input = { + readonly text: Endpoint7_0Request["payload"]["text"] + readonly models: Endpoint7_0Request["payload"]["models"] + readonly requestId?: Endpoint7_0Request["payload"]["requestId"] +} +const Endpoint7_0 = (raw: RawClient["server.variant"]) => (input: Endpoint7_0Input) => + raw["variant.paraphrase"]({ + payload: { text: input["text"], models: input["models"], requestId: input["requestId"] }, + }).pipe(Effect.mapError(mapClientError)) -type Endpoint7_1Request = Parameters[0] +type Endpoint7_1Request = Parameters[0] type Endpoint7_1Input = { - readonly integrationID: Endpoint7_1Request["params"]["integrationID"] - readonly location?: Endpoint7_1Request["query"]["location"] + readonly messages: Endpoint7_1Request["payload"]["messages"] + readonly models: Endpoint7_1Request["payload"]["models"] + readonly requestId?: Endpoint7_1Request["payload"]["requestId"] } -const Endpoint7_1 = (raw: RawClient["server.integration"]) => (input: Endpoint7_1Input) => +const Endpoint7_1 = (raw: RawClient["server.variant"]) => (input: Endpoint7_1Input) => + raw["variant.compare"]({ + payload: { messages: input["messages"], models: input["models"], requestId: input["requestId"] }, + }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup7 = (raw: RawClient["server.variant"]) => ({ paraphrase: Endpoint7_0(raw), compare: Endpoint7_1(raw) }) + +type Endpoint8_0Request = Parameters[0] +type Endpoint8_0Input = { readonly location?: Endpoint8_0Request["query"]["location"] } +const Endpoint8_0 = (raw: RawClient["server.integration"]) => (input?: Endpoint8_0Input) => + raw["integration.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint8_1Request = Parameters[0] +type Endpoint8_1Input = { + readonly integrationID: Endpoint8_1Request["params"]["integrationID"] + readonly location?: Endpoint8_1Request["query"]["location"] +} +const Endpoint8_1 = (raw: RawClient["server.integration"]) => (input: Endpoint8_1Input) => raw["integration.get"]({ params: { integrationID: input["integrationID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint7_2Request = Parameters[0] -type Endpoint7_2Input = { - readonly integrationID: Endpoint7_2Request["params"]["integrationID"] - readonly location?: Endpoint7_2Request["query"]["location"] - readonly key: Endpoint7_2Request["payload"]["key"] - readonly label?: Endpoint7_2Request["payload"]["label"] +type Endpoint8_2Request = Parameters[0] +type Endpoint8_2Input = { + readonly integrationID: Endpoint8_2Request["params"]["integrationID"] + readonly location?: Endpoint8_2Request["query"]["location"] + readonly key: Endpoint8_2Request["payload"]["key"] + readonly label?: Endpoint8_2Request["payload"]["label"] } -const Endpoint7_2 = (raw: RawClient["server.integration"]) => (input: Endpoint7_2Input) => +const Endpoint8_2 = (raw: RawClient["server.integration"]) => (input: Endpoint8_2Input) => raw["integration.connect.key"]({ params: { integrationID: input["integrationID"] }, query: { location: input["location"] }, payload: { key: input["key"], label: input["label"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint7_3Request = Parameters[0] -type Endpoint7_3Input = { - readonly integrationID: Endpoint7_3Request["params"]["integrationID"] - readonly location?: Endpoint7_3Request["query"]["location"] - readonly methodID: Endpoint7_3Request["payload"]["methodID"] - readonly inputs: Endpoint7_3Request["payload"]["inputs"] - readonly label?: Endpoint7_3Request["payload"]["label"] +type Endpoint8_3Request = Parameters[0] +type Endpoint8_3Input = { + readonly integrationID: Endpoint8_3Request["params"]["integrationID"] + readonly location?: Endpoint8_3Request["query"]["location"] + readonly methodID: Endpoint8_3Request["payload"]["methodID"] + readonly inputs: Endpoint8_3Request["payload"]["inputs"] + readonly label?: Endpoint8_3Request["payload"]["label"] } -const Endpoint7_3 = (raw: RawClient["server.integration"]) => (input: Endpoint7_3Input) => +const Endpoint8_3 = (raw: RawClient["server.integration"]) => (input: Endpoint8_3Input) => raw["integration.connect.oauth"]({ params: { integrationID: input["integrationID"] }, query: { location: input["location"] }, payload: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint7_4Request = Parameters[0] -type Endpoint7_4Input = { - readonly attemptID: Endpoint7_4Request["params"]["attemptID"] - readonly location?: Endpoint7_4Request["query"]["location"] +type Endpoint8_4Request = Parameters[0] +type Endpoint8_4Input = { + readonly attemptID: Endpoint8_4Request["params"]["attemptID"] + readonly location?: Endpoint8_4Request["query"]["location"] } -const Endpoint7_4 = (raw: RawClient["server.integration"]) => (input: Endpoint7_4Input) => +const Endpoint8_4 = (raw: RawClient["server.integration"]) => (input: Endpoint8_4Input) => raw["integration.attempt.status"]({ params: { attemptID: input["attemptID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint7_5Request = Parameters[0] -type Endpoint7_5Input = { - readonly attemptID: Endpoint7_5Request["params"]["attemptID"] - readonly location?: Endpoint7_5Request["query"]["location"] - readonly code?: Endpoint7_5Request["payload"]["code"] +type Endpoint8_5Request = Parameters[0] +type Endpoint8_5Input = { + readonly attemptID: Endpoint8_5Request["params"]["attemptID"] + readonly location?: Endpoint8_5Request["query"]["location"] + readonly code?: Endpoint8_5Request["payload"]["code"] } -const Endpoint7_5 = (raw: RawClient["server.integration"]) => (input: Endpoint7_5Input) => +const Endpoint8_5 = (raw: RawClient["server.integration"]) => (input: Endpoint8_5Input) => raw["integration.attempt.complete"]({ params: { attemptID: input["attemptID"] }, query: { location: input["location"] }, payload: { code: input["code"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint7_6Request = Parameters[0] -type Endpoint7_6Input = { - readonly attemptID: Endpoint7_6Request["params"]["attemptID"] - readonly location?: Endpoint7_6Request["query"]["location"] +type Endpoint8_6Request = Parameters[0] +type Endpoint8_6Input = { + readonly attemptID: Endpoint8_6Request["params"]["attemptID"] + readonly location?: Endpoint8_6Request["query"]["location"] } -const Endpoint7_6 = (raw: RawClient["server.integration"]) => (input: Endpoint7_6Input) => +const Endpoint8_6 = (raw: RawClient["server.integration"]) => (input: Endpoint8_6Input) => raw["integration.attempt.cancel"]({ params: { attemptID: input["attemptID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup7 = (raw: RawClient["server.integration"]) => ({ - list: Endpoint7_0(raw), - get: Endpoint7_1(raw), - connectKey: Endpoint7_2(raw), - connectOauth: Endpoint7_3(raw), - attemptStatus: Endpoint7_4(raw), - attemptComplete: Endpoint7_5(raw), - attemptCancel: Endpoint7_6(raw), +const adaptGroup8 = (raw: RawClient["server.integration"]) => ({ + list: Endpoint8_0(raw), + get: Endpoint8_1(raw), + connectKey: Endpoint8_2(raw), + connectOauth: Endpoint8_3(raw), + attemptStatus: Endpoint8_4(raw), + attemptComplete: Endpoint8_5(raw), + attemptCancel: Endpoint8_6(raw), }) -type Endpoint8_0Request = Parameters[0] -type Endpoint8_0Input = { - readonly credentialID: Endpoint8_0Request["params"]["credentialID"] - readonly location?: Endpoint8_0Request["query"]["location"] - readonly label: Endpoint8_0Request["payload"]["label"] +type Endpoint9_0Request = Parameters[0] +type Endpoint9_0Input = { + readonly credentialID: Endpoint9_0Request["params"]["credentialID"] + readonly location?: Endpoint9_0Request["query"]["location"] + readonly label: Endpoint9_0Request["payload"]["label"] } -const Endpoint8_0 = (raw: RawClient["server.credential"]) => (input: Endpoint8_0Input) => +const Endpoint9_0 = (raw: RawClient["server.credential"]) => (input: Endpoint9_0Input) => raw["credential.update"]({ params: { credentialID: input["credentialID"] }, query: { location: input["location"] }, payload: { label: input["label"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint8_1Request = Parameters[0] -type Endpoint8_1Input = { - readonly credentialID: Endpoint8_1Request["params"]["credentialID"] - readonly location?: Endpoint8_1Request["query"]["location"] +type Endpoint9_1Request = Parameters[0] +type Endpoint9_1Input = { + readonly credentialID: Endpoint9_1Request["params"]["credentialID"] + readonly location?: Endpoint9_1Request["query"]["location"] } -const Endpoint8_1 = (raw: RawClient["server.credential"]) => (input: Endpoint8_1Input) => +const Endpoint9_1 = (raw: RawClient["server.credential"]) => (input: Endpoint9_1Input) => raw["credential.remove"]({ params: { credentialID: input["credentialID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup8 = (raw: RawClient["server.credential"]) => ({ update: Endpoint8_0(raw), remove: Endpoint8_1(raw) }) +const adaptGroup9 = (raw: RawClient["server.credential"]) => ({ update: Endpoint9_0(raw), remove: Endpoint9_1(raw) }) -type Endpoint9_0Request = Parameters[0] -type Endpoint9_0Input = { readonly location?: Endpoint9_0Request["query"]["location"] } -const Endpoint9_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint9_0Input) => +type Endpoint10_0Request = Parameters[0] +type Endpoint10_0Input = { readonly location?: Endpoint10_0Request["query"]["location"] } +const Endpoint10_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint10_0Input) => raw["permission.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint9_1Request = Parameters[0] -type Endpoint9_1Input = { readonly projectID?: Endpoint9_1Request["query"]["projectID"] } -const Endpoint9_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint9_1Input) => +type Endpoint10_1Request = Parameters[0] +type Endpoint10_1Input = { readonly projectID?: Endpoint10_1Request["query"]["projectID"] } +const Endpoint10_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint10_1Input) => raw["permission.saved.list"]({ query: { projectID: input?.["projectID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint9_2Request = Parameters[0] -type Endpoint9_2Input = { readonly id: Endpoint9_2Request["params"]["id"] } -const Endpoint9_2 = (raw: RawClient["server.permission"]) => (input: Endpoint9_2Input) => +type Endpoint10_2Request = Parameters[0] +type Endpoint10_2Input = { readonly id: Endpoint10_2Request["params"]["id"] } +const Endpoint10_2 = (raw: RawClient["server.permission"]) => (input: Endpoint10_2Input) => raw["permission.saved.remove"]({ params: { id: input["id"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint9_3Request = Parameters[0] -type Endpoint9_3Input = { - readonly sessionID: Endpoint9_3Request["params"]["sessionID"] - readonly id?: Endpoint9_3Request["payload"]["id"] - readonly action: Endpoint9_3Request["payload"]["action"] - readonly resources: Endpoint9_3Request["payload"]["resources"] - readonly save?: Endpoint9_3Request["payload"]["save"] - readonly metadata?: Endpoint9_3Request["payload"]["metadata"] - readonly source?: Endpoint9_3Request["payload"]["source"] - readonly agent?: Endpoint9_3Request["payload"]["agent"] -} -const Endpoint9_3 = (raw: RawClient["server.permission"]) => (input: Endpoint9_3Input) => +type Endpoint10_3Request = Parameters[0] +type Endpoint10_3Input = { + readonly sessionID: Endpoint10_3Request["params"]["sessionID"] + readonly id?: Endpoint10_3Request["payload"]["id"] + readonly action: Endpoint10_3Request["payload"]["action"] + readonly resources: Endpoint10_3Request["payload"]["resources"] + readonly save?: Endpoint10_3Request["payload"]["save"] + readonly metadata?: Endpoint10_3Request["payload"]["metadata"] + readonly source?: Endpoint10_3Request["payload"]["source"] + readonly agent?: Endpoint10_3Request["payload"]["agent"] +} +const Endpoint10_3 = (raw: RawClient["server.permission"]) => (input: Endpoint10_3Input) => raw["session.permission.create"]({ params: { sessionID: input["sessionID"] }, payload: { @@ -428,87 +452,87 @@ const Endpoint9_3 = (raw: RawClient["server.permission"]) => (input: Endpoint9_3 Effect.map((value) => value.data), ) -type Endpoint9_4Request = Parameters[0] -type Endpoint9_4Input = { readonly sessionID: Endpoint9_4Request["params"]["sessionID"] } -const Endpoint9_4 = (raw: RawClient["server.permission"]) => (input: Endpoint9_4Input) => +type Endpoint10_4Request = Parameters[0] +type Endpoint10_4Input = { readonly sessionID: Endpoint10_4Request["params"]["sessionID"] } +const Endpoint10_4 = (raw: RawClient["server.permission"]) => (input: Endpoint10_4Input) => raw["session.permission.list"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint9_5Request = Parameters[0] -type Endpoint9_5Input = { - readonly sessionID: Endpoint9_5Request["params"]["sessionID"] - readonly requestID: Endpoint9_5Request["params"]["requestID"] +type Endpoint10_5Request = Parameters[0] +type Endpoint10_5Input = { + readonly sessionID: Endpoint10_5Request["params"]["sessionID"] + readonly requestID: Endpoint10_5Request["params"]["requestID"] } -const Endpoint9_5 = (raw: RawClient["server.permission"]) => (input: Endpoint9_5Input) => +const Endpoint10_5 = (raw: RawClient["server.permission"]) => (input: Endpoint10_5Input) => raw["session.permission.get"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint9_6Request = Parameters[0] -type Endpoint9_6Input = { - readonly sessionID: Endpoint9_6Request["params"]["sessionID"] - readonly requestID: Endpoint9_6Request["params"]["requestID"] - readonly reply: Endpoint9_6Request["payload"]["reply"] - readonly message?: Endpoint9_6Request["payload"]["message"] +type Endpoint10_6Request = Parameters[0] +type Endpoint10_6Input = { + readonly sessionID: Endpoint10_6Request["params"]["sessionID"] + readonly requestID: Endpoint10_6Request["params"]["requestID"] + readonly reply: Endpoint10_6Request["payload"]["reply"] + readonly message?: Endpoint10_6Request["payload"]["message"] } -const Endpoint9_6 = (raw: RawClient["server.permission"]) => (input: Endpoint9_6Input) => +const Endpoint10_6 = (raw: RawClient["server.permission"]) => (input: Endpoint10_6Input) => raw["session.permission.reply"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] }, payload: { reply: input["reply"], message: input["message"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup9 = (raw: RawClient["server.permission"]) => ({ - listRequests: Endpoint9_0(raw), - listSaved: Endpoint9_1(raw), - removeSaved: Endpoint9_2(raw), - create: Endpoint9_3(raw), - list: Endpoint9_4(raw), - get: Endpoint9_5(raw), - reply: Endpoint9_6(raw), +const adaptGroup10 = (raw: RawClient["server.permission"]) => ({ + listRequests: Endpoint10_0(raw), + listSaved: Endpoint10_1(raw), + removeSaved: Endpoint10_2(raw), + create: Endpoint10_3(raw), + list: Endpoint10_4(raw), + get: Endpoint10_5(raw), + reply: Endpoint10_6(raw), }) -type Endpoint10_0Request = Parameters[0] -type Endpoint10_0Input = { - readonly location?: Endpoint10_0Request["query"]["location"] - readonly path?: Endpoint10_0Request["query"]["path"] +type Endpoint11_0Request = Parameters[0] +type Endpoint11_0Input = { + readonly location?: Endpoint11_0Request["query"]["location"] + readonly path?: Endpoint11_0Request["query"]["path"] } -const Endpoint10_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint10_0Input) => +const Endpoint11_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint11_0Input) => raw["fs.list"]({ query: { location: input?.["location"], path: input?.["path"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint10_1Request = Parameters[0] -type Endpoint10_1Input = { - readonly location?: Endpoint10_1Request["query"]["location"] - readonly query: Endpoint10_1Request["query"]["query"] - readonly type?: Endpoint10_1Request["query"]["type"] - readonly limit?: Endpoint10_1Request["query"]["limit"] +type Endpoint11_1Request = Parameters[0] +type Endpoint11_1Input = { + readonly location?: Endpoint11_1Request["query"]["location"] + readonly query: Endpoint11_1Request["query"]["query"] + readonly type?: Endpoint11_1Request["query"]["type"] + readonly limit?: Endpoint11_1Request["query"]["limit"] } -const Endpoint10_1 = (raw: RawClient["server.fs"]) => (input: Endpoint10_1Input) => +const Endpoint11_1 = (raw: RawClient["server.fs"]) => (input: Endpoint11_1Input) => raw["fs.find"]({ query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup10 = (raw: RawClient["server.fs"]) => ({ list: Endpoint10_0(raw), find: Endpoint10_1(raw) }) +const adaptGroup11 = (raw: RawClient["server.fs"]) => ({ list: Endpoint11_0(raw), find: Endpoint11_1(raw) }) -type Endpoint11_0Request = Parameters[0] -type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] } -const Endpoint11_0 = (raw: RawClient["server.command"]) => (input?: Endpoint11_0Input) => +type Endpoint12_0Request = Parameters[0] +type Endpoint12_0Input = { readonly location?: Endpoint12_0Request["query"]["location"] } +const Endpoint12_0 = (raw: RawClient["server.command"]) => (input?: Endpoint12_0Input) => raw["command.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup11 = (raw: RawClient["server.command"]) => ({ list: Endpoint11_0(raw) }) +const adaptGroup12 = (raw: RawClient["server.command"]) => ({ list: Endpoint12_0(raw) }) -type Endpoint12_0Request = Parameters[0] -type Endpoint12_0Input = { readonly location?: Endpoint12_0Request["query"]["location"] } -const Endpoint12_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint12_0Input) => +type Endpoint13_0Request = Parameters[0] +type Endpoint13_0Input = { readonly location?: Endpoint13_0Request["query"]["location"] } +const Endpoint13_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint13_0Input) => raw["skill.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup12 = (raw: RawClient["server.skill"]) => ({ list: Endpoint12_0(raw) }) +const adaptGroup13 = (raw: RawClient["server.skill"]) => ({ list: Endpoint13_0(raw) }) -const Endpoint13_0 = (raw: RawClient["server.event"]) => () => +const Endpoint14_0 = (raw: RawClient["server.event"]) => () => Stream.unwrap( raw["event.subscribe"]({}).pipe( Effect.mapError(mapClientError), @@ -516,23 +540,23 @@ const Endpoint13_0 = (raw: RawClient["server.event"]) => () => ), ) -const adaptGroup13 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint13_0(raw) }) +const adaptGroup14 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint14_0(raw) }) -type Endpoint14_0Request = Parameters[0] -type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] } -const Endpoint14_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint14_0Input) => +type Endpoint15_0Request = Parameters[0] +type Endpoint15_0Input = { readonly location?: Endpoint15_0Request["query"]["location"] } +const Endpoint15_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint15_0Input) => raw["pty.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint14_1Request = Parameters[0] -type Endpoint14_1Input = { - readonly location?: Endpoint14_1Request["query"]["location"] - readonly command?: Endpoint14_1Request["payload"]["command"] - readonly args?: Endpoint14_1Request["payload"]["args"] - readonly cwd?: Endpoint14_1Request["payload"]["cwd"] - readonly title?: Endpoint14_1Request["payload"]["title"] - readonly env?: Endpoint14_1Request["payload"]["env"] +type Endpoint15_1Request = Parameters[0] +type Endpoint15_1Input = { + readonly location?: Endpoint15_1Request["query"]["location"] + readonly command?: Endpoint15_1Request["payload"]["command"] + readonly args?: Endpoint15_1Request["payload"]["args"] + readonly cwd?: Endpoint15_1Request["payload"]["cwd"] + readonly title?: Endpoint15_1Request["payload"]["title"] + readonly env?: Endpoint15_1Request["payload"]["env"] } -const Endpoint14_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint14_1Input) => +const Endpoint15_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint15_1Input) => raw["pty.create"]({ query: { location: input?.["location"] }, payload: { @@ -544,141 +568,141 @@ const Endpoint14_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint14_1Inpu }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint14_2Request = Parameters[0] -type Endpoint14_2Input = { - readonly ptyID: Endpoint14_2Request["params"]["ptyID"] - readonly location?: Endpoint14_2Request["query"]["location"] +type Endpoint15_2Request = Parameters[0] +type Endpoint15_2Input = { + readonly ptyID: Endpoint15_2Request["params"]["ptyID"] + readonly location?: Endpoint15_2Request["query"]["location"] } -const Endpoint14_2 = (raw: RawClient["server.pty"]) => (input: Endpoint14_2Input) => +const Endpoint15_2 = (raw: RawClient["server.pty"]) => (input: Endpoint15_2Input) => raw["pty.get"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint14_3Request = Parameters[0] -type Endpoint14_3Input = { - readonly ptyID: Endpoint14_3Request["params"]["ptyID"] - readonly location?: Endpoint14_3Request["query"]["location"] - readonly title?: Endpoint14_3Request["payload"]["title"] - readonly size?: Endpoint14_3Request["payload"]["size"] +type Endpoint15_3Request = Parameters[0] +type Endpoint15_3Input = { + readonly ptyID: Endpoint15_3Request["params"]["ptyID"] + readonly location?: Endpoint15_3Request["query"]["location"] + readonly title?: Endpoint15_3Request["payload"]["title"] + readonly size?: Endpoint15_3Request["payload"]["size"] } -const Endpoint14_3 = (raw: RawClient["server.pty"]) => (input: Endpoint14_3Input) => +const Endpoint15_3 = (raw: RawClient["server.pty"]) => (input: Endpoint15_3Input) => raw["pty.update"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] }, payload: { title: input["title"], size: input["size"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint14_4Request = Parameters[0] -type Endpoint14_4Input = { - readonly ptyID: Endpoint14_4Request["params"]["ptyID"] - readonly location?: Endpoint14_4Request["query"]["location"] +type Endpoint15_4Request = Parameters[0] +type Endpoint15_4Input = { + readonly ptyID: Endpoint15_4Request["params"]["ptyID"] + readonly location?: Endpoint15_4Request["query"]["location"] } -const Endpoint14_4 = (raw: RawClient["server.pty"]) => (input: Endpoint14_4Input) => +const Endpoint15_4 = (raw: RawClient["server.pty"]) => (input: Endpoint15_4Input) => raw["pty.remove"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe( Effect.mapError(mapClientError), ) -const adaptGroup14 = (raw: RawClient["server.pty"]) => ({ - list: Endpoint14_0(raw), - create: Endpoint14_1(raw), - get: Endpoint14_2(raw), - update: Endpoint14_3(raw), - remove: Endpoint14_4(raw), +const adaptGroup15 = (raw: RawClient["server.pty"]) => ({ + list: Endpoint15_0(raw), + create: Endpoint15_1(raw), + get: Endpoint15_2(raw), + update: Endpoint15_3(raw), + remove: Endpoint15_4(raw), }) -type Endpoint15_0Request = Parameters[0] -type Endpoint15_0Input = { readonly location?: Endpoint15_0Request["query"]["location"] } -const Endpoint15_0 = (raw: RawClient["server.question"]) => (input?: Endpoint15_0Input) => +type Endpoint16_0Request = Parameters[0] +type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] } +const Endpoint16_0 = (raw: RawClient["server.question"]) => (input?: Endpoint16_0Input) => raw["question.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint15_1Request = Parameters[0] -type Endpoint15_1Input = { readonly sessionID: Endpoint15_1Request["params"]["sessionID"] } -const Endpoint15_1 = (raw: RawClient["server.question"]) => (input: Endpoint15_1Input) => +type Endpoint16_1Request = Parameters[0] +type Endpoint16_1Input = { readonly sessionID: Endpoint16_1Request["params"]["sessionID"] } +const Endpoint16_1 = (raw: RawClient["server.question"]) => (input: Endpoint16_1Input) => raw["session.question.list"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint15_2Request = Parameters[0] -type Endpoint15_2Input = { - readonly sessionID: Endpoint15_2Request["params"]["sessionID"] - readonly requestID: Endpoint15_2Request["params"]["requestID"] - readonly answers: Endpoint15_2Request["payload"]["answers"] +type Endpoint16_2Request = Parameters[0] +type Endpoint16_2Input = { + readonly sessionID: Endpoint16_2Request["params"]["sessionID"] + readonly requestID: Endpoint16_2Request["params"]["requestID"] + readonly answers: Endpoint16_2Request["payload"]["answers"] } -const Endpoint15_2 = (raw: RawClient["server.question"]) => (input: Endpoint15_2Input) => +const Endpoint16_2 = (raw: RawClient["server.question"]) => (input: Endpoint16_2Input) => raw["session.question.reply"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] }, payload: { answers: input["answers"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint15_3Request = Parameters[0] -type Endpoint15_3Input = { - readonly sessionID: Endpoint15_3Request["params"]["sessionID"] - readonly requestID: Endpoint15_3Request["params"]["requestID"] +type Endpoint16_3Request = Parameters[0] +type Endpoint16_3Input = { + readonly sessionID: Endpoint16_3Request["params"]["sessionID"] + readonly requestID: Endpoint16_3Request["params"]["requestID"] } -const Endpoint15_3 = (raw: RawClient["server.question"]) => (input: Endpoint15_3Input) => +const Endpoint16_3 = (raw: RawClient["server.question"]) => (input: Endpoint16_3Input) => raw["session.question.reject"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe( Effect.mapError(mapClientError), ) -const adaptGroup15 = (raw: RawClient["server.question"]) => ({ - listRequests: Endpoint15_0(raw), - list: Endpoint15_1(raw), - reply: Endpoint15_2(raw), - reject: Endpoint15_3(raw), +const adaptGroup16 = (raw: RawClient["server.question"]) => ({ + listRequests: Endpoint16_0(raw), + list: Endpoint16_1(raw), + reply: Endpoint16_2(raw), + reject: Endpoint16_3(raw), }) -type Endpoint16_0Request = Parameters[0] -type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] } -const Endpoint16_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint16_0Input) => +type Endpoint17_0Request = Parameters[0] +type Endpoint17_0Input = { readonly location?: Endpoint17_0Request["query"]["location"] } +const Endpoint17_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint17_0Input) => raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup16 = (raw: RawClient["server.reference"]) => ({ list: Endpoint16_0(raw) }) +const adaptGroup17 = (raw: RawClient["server.reference"]) => ({ list: Endpoint17_0(raw) }) -type Endpoint17_0Request = Parameters[0] -type Endpoint17_0Input = { - readonly projectID: Endpoint17_0Request["params"]["projectID"] - readonly location?: Endpoint17_0Request["query"]["location"] - readonly strategy: Endpoint17_0Request["payload"]["strategy"] - readonly directory: Endpoint17_0Request["payload"]["directory"] - readonly name?: Endpoint17_0Request["payload"]["name"] +type Endpoint18_0Request = Parameters[0] +type Endpoint18_0Input = { + readonly projectID: Endpoint18_0Request["params"]["projectID"] + readonly location?: Endpoint18_0Request["query"]["location"] + readonly strategy: Endpoint18_0Request["payload"]["strategy"] + readonly directory: Endpoint18_0Request["payload"]["directory"] + readonly name?: Endpoint18_0Request["payload"]["name"] } -const Endpoint17_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_0Input) => +const Endpoint18_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint18_0Input) => raw["projectCopy.create"]({ params: { projectID: input["projectID"] }, query: { location: input["location"] }, payload: { strategy: input["strategy"], directory: input["directory"], name: input["name"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint17_1Request = Parameters[0] -type Endpoint17_1Input = { - readonly projectID: Endpoint17_1Request["params"]["projectID"] - readonly location?: Endpoint17_1Request["query"]["location"] - readonly directory: Endpoint17_1Request["payload"]["directory"] - readonly force: Endpoint17_1Request["payload"]["force"] +type Endpoint18_1Request = Parameters[0] +type Endpoint18_1Input = { + readonly projectID: Endpoint18_1Request["params"]["projectID"] + readonly location?: Endpoint18_1Request["query"]["location"] + readonly directory: Endpoint18_1Request["payload"]["directory"] + readonly force: Endpoint18_1Request["payload"]["force"] } -const Endpoint17_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_1Input) => +const Endpoint18_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint18_1Input) => raw["projectCopy.remove"]({ params: { projectID: input["projectID"] }, query: { location: input["location"] }, payload: { directory: input["directory"], force: input["force"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint17_2Request = Parameters[0] -type Endpoint17_2Input = { - readonly projectID: Endpoint17_2Request["params"]["projectID"] - readonly location?: Endpoint17_2Request["query"]["location"] +type Endpoint18_2Request = Parameters[0] +type Endpoint18_2Input = { + readonly projectID: Endpoint18_2Request["params"]["projectID"] + readonly location?: Endpoint18_2Request["query"]["location"] } -const Endpoint17_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_2Input) => +const Endpoint18_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint18_2Input) => raw["projectCopy.refresh"]({ params: { projectID: input["projectID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup17 = (raw: RawClient["server.projectCopy"]) => ({ - create: Endpoint17_0(raw), - remove: Endpoint17_1(raw), - refresh: Endpoint17_2(raw), +const adaptGroup18 = (raw: RawClient["server.projectCopy"]) => ({ + create: Endpoint18_0(raw), + remove: Endpoint18_1(raw), + refresh: Endpoint18_2(raw), }) const adaptClient = (raw: RawClient) => ({ @@ -689,17 +713,18 @@ const adaptClient = (raw: RawClient) => ({ messages: adaptGroup4(raw["server.message"]), models: adaptGroup5(raw["server.model"]), providers: adaptGroup6(raw["server.provider"]), - integrations: adaptGroup7(raw["server.integration"]), - credentials: adaptGroup8(raw["server.credential"]), - permissions: adaptGroup9(raw["server.permission"]), - files: adaptGroup10(raw["server.fs"]), - commands: adaptGroup11(raw["server.command"]), - skills: adaptGroup12(raw["server.skill"]), - events: adaptGroup13(raw["server.event"]), - ptys: adaptGroup14(raw["server.pty"]), - questions: adaptGroup15(raw["server.question"]), - references: adaptGroup16(raw["server.reference"]), - projectCopies: adaptGroup17(raw["server.projectCopy"]), + "server.variant": adaptGroup7(raw["server.variant"]), + integrations: adaptGroup8(raw["server.integration"]), + credentials: adaptGroup9(raw["server.credential"]), + permissions: adaptGroup10(raw["server.permission"]), + files: adaptGroup11(raw["server.fs"]), + commands: adaptGroup12(raw["server.command"]), + skills: adaptGroup13(raw["server.skill"]), + events: adaptGroup14(raw["server.event"]), + ptys: adaptGroup15(raw["server.pty"]), + questions: adaptGroup16(raw["server.question"]), + references: adaptGroup17(raw["server.reference"]), + projectCopies: adaptGroup18(raw["server.projectCopy"]), }) export const make = (options?: { readonly baseUrl?: URL | string }) => diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index 27ec3d81ba..521b25e0b4 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -45,6 +45,10 @@ import type { ProvidersListOutput, ProvidersGetInput, ProvidersGetOutput, + ServerVariantParaphraseInput, + ServerVariantParaphraseOutput, + ServerVariantCompareInput, + ServerVariantCompareOutput, IntegrationsListInput, IntegrationsListOutput, IntegrationsGetInput, @@ -547,6 +551,32 @@ export function make(options: ClientOptions) { requestOptions, ), }, + "server.variant": { + paraphrase: (input: ServerVariantParaphraseInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/variant/paraphrase`, + body: { text: input["text"], models: input["models"], requestId: input["requestId"] }, + successStatus: 200, + declaredStatuses: [400, 401, 404, 503], + empty: false, + }, + requestOptions, + ), + compare: (input: ServerVariantCompareInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/variant/compare`, + body: { messages: input["messages"], models: input["models"], requestId: input["requestId"] }, + successStatus: 200, + declaredStatuses: [400, 401, 404, 503], + empty: false, + }, + requestOptions, + ), + }, integrations: { list: (input?: IntegrationsListInput, requestOptions?: RequestOptions) => request( diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index ad1bad824e..948d394efd 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -2057,6 +2057,76 @@ export type ProvidersGetOutput = { } } +export type ServerVariantParaphraseInput = { + readonly text: { + readonly text: string + readonly models: ReadonlyArray<{ readonly model: string; readonly variant?: string }> + readonly requestId?: string + }["text"] + readonly models: { + readonly text: string + readonly models: ReadonlyArray<{ readonly model: string; readonly variant?: string }> + readonly requestId?: string + }["models"] + readonly requestId?: { + readonly text: string + readonly models: ReadonlyArray<{ readonly model: string; readonly variant?: string }> + readonly requestId?: string + }["requestId"] +} + +export type ServerVariantParaphraseOutput = { + readonly variants: ReadonlyArray<{ + readonly slot: number + readonly model: string + readonly text?: string + readonly error?: string + readonly redrob?: { + readonly requestId?: string + readonly routedModel?: string + readonly upstreamProvider?: string + readonly latencyMs?: number + readonly costUsd?: number + } + }> + readonly totalCostUsd: number +} + +export type ServerVariantCompareInput = { + readonly messages: { + readonly messages: ReadonlyArray<{ readonly role: "system" | "user" | "assistant"; readonly content: string }> + readonly models: ReadonlyArray<{ readonly model: string; readonly variant?: string }> + readonly requestId?: string + }["messages"] + readonly models: { + readonly messages: ReadonlyArray<{ readonly role: "system" | "user" | "assistant"; readonly content: string }> + readonly models: ReadonlyArray<{ readonly model: string; readonly variant?: string }> + readonly requestId?: string + }["models"] + readonly requestId?: { + readonly messages: ReadonlyArray<{ readonly role: "system" | "user" | "assistant"; readonly content: string }> + readonly models: ReadonlyArray<{ readonly model: string; readonly variant?: string }> + readonly requestId?: string + }["requestId"] +} + +export type ServerVariantCompareOutput = { + readonly variants: ReadonlyArray<{ + readonly slot: number + readonly model: string + readonly text?: string + readonly error?: string + readonly redrob?: { + readonly requestId?: string + readonly routedModel?: string + readonly upstreamProvider?: string + readonly latencyMs?: number + readonly costUsd?: number + } + }> + readonly totalCostUsd: number +} + export type IntegrationsListInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined diff --git a/packages/core/src/console-key.ts b/packages/core/src/console-key.ts new file mode 100644 index 0000000000..1b542b5dfe --- /dev/null +++ b/packages/core/src/console-key.ts @@ -0,0 +1,76 @@ +export * as ConsoleKey from "./console-key" + +import { join } from "node:path" +import { Effect } from "effect" + +import { Global } from "./global" +import { Integration } from "./integration" + +/** + * The console API key, from wherever the user actually put it. + * + * THREE ORIGINS, and they are not interchangeable historically: + * + * - `REDROB_API_KEY` is what a terminal user exports. + * - The Credential store is what `redrob providers login` writes. It is SQL. + * - `auth.json` is what `PUT /auth/redrob` writes, which is the route the DESKTOP APP uses for both of + * its connect paths -- the pasted key and "Connect Redrob". That flow ends by handing the key to this + * same route, so the app never populates the Credential store at all. + * + * Nothing bridges the last two: one is SQL, the other a file, and no migration copies either into the + * other. So every consumer has to read all three. + * + * This lives in its own module because reading only some of them is a bug that has already shipped once: + * the dynamic model catalogue checked the environment and the Credential store, so a user who connected + * through the desktop app had a key present, in `auth.json`, that the catalogue could not see -- it took + * the keyless branch and silently served the six-id fallback list. A second consumer re-deriving this + * logic is how that happens again, so there is one implementation and both callers use it. + * + * Note the SHAPES differ as well as the locations: the Credential store discriminates on `type: "key"`, + * `auth.json` on `type: "api"`. Matching only one spelling was the second half of that same bug. + */ + +/** `auth.json`, the desktop app's store. Absent or malformed reads as "no key" rather than failing. */ +function fromAuthStore(): Effect.Effect { + return Effect.tryPromise(() => Bun.file(join(Global.Path.data, "auth.json")).json()).pipe( + Effect.map((data) => { + if (typeof data !== "object" || data === null) return undefined + const entry = (data as Record)["redrob"] + if (typeof entry !== "object" || entry === null) return undefined + const record = entry as Record + if (record["type"] !== "api") return undefined + const key = record["key"] + return typeof key === "string" && key.trim() ? key : undefined + }), + Effect.orElseSucceed(() => undefined), + ) +} + +/** + * Resolve the key, in the order a user would expect to win. + * + * The environment first, because an explicitly exported value is the one a person set most recently and + * most deliberately -- overriding a stored credential for one run is a thing people do, and the reverse + * would make that impossible. + */ +export const resolve = Effect.fn("ConsoleKey.resolve")(function* () { + const fromEnv = process.env["REDROB_API_KEY"] + if (fromEnv) return fromEnv + + /* + Read through `Integration`, not `Credential` directly. Both reach the same store, but `Credential` is not + in scope everywhere this runs -- the HTTP handlers have `Integration` and resolving through it returns + the same material. Using the lower-level service typechecked and then failed at request time with + "Service not found", which is the kind of error a type system cannot catch for you. + */ + const integration = yield* Integration.Service + const connection = yield* integration.connection.active(Integration.ID.make("redrob")) + if (connection) { + const value = yield* integration.connection + .resolve(connection) + .pipe(Effect.orElseSucceed(() => undefined)) + if (value?.type === "key" && value.key.trim()) return value.key + } + + return yield* fromAuthStore() +}) diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index df56580f04..e4dc288133 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -18,6 +18,17 @@ export const Flag = { REDROB_AUTO_HEAP_SNAPSHOT: truthy("REDROB_AUTO_HEAP_SNAPSHOT"), REDROB_GIT_BASH_PATH: process.env["REDROB_GIT_BASH_PATH"], + /** + * Point the console calls at a different base URL. + * + * Exists for verifying against a console running locally. Without it the only way to exercise a change + * to those endpoints is to deploy it, which is how a broken `/variants/paraphrase` reached production and + * stayed there: nothing could call it except the real thing. + * + * Never set in a shipped build, and it does not change where credentials come from -- a local console + * still wants a key it recognises. + */ + REDROB_CONSOLE_URL: process.env["REDROB_CONSOLE_URL"], REDROB_CONFIG: process.env["REDROB_CONFIG"], REDROB_CONFIG_CONTENT: process.env["REDROB_CONFIG_CONTENT"], REDROB_DISABLE_AUTOUPDATE: truthy("REDROB_DISABLE_AUTOUPDATE"), diff --git a/packages/core/src/plugin/provider/redrob-constants.ts b/packages/core/src/plugin/provider/redrob-constants.ts index d5e9733cdc..e4fa82e0ec 100644 --- a/packages/core/src/plugin/provider/redrob-constants.ts +++ b/packages/core/src/plugin/provider/redrob-constants.ts @@ -3,11 +3,28 @@ // ModelsDev.Service (packages/core/src/models-dev.ts) can import them without creating // an import cycle through the heavier plugin/event modules. +import { Flag } from "../../flag/flag" + // The real console.redrob.ai API is an OpenAI-standard base URL. SDKs and the dynamic // catalog fetch append paths to it: /chat/completions for inference and /models for the // OpenAI-standard model listing. It is served by the trusted @ai-sdk/openai-compatible // package (pinned by ConfigProviderPlugin). -export const CONSOLE_URL = "https://console.redrob.ai/api/backend/v1" +// +// Resolved HERE rather than at each call site, because a per-consumer `Flag.REDROB_CONSOLE_URL ?? +// CONSOLE_URL` is what made the override half-apply: the variants service honoured it while the +// session's own inference path, the catalog fetch and the provider registration all still went to +// production. Pointing the CLI at a local console then produced a console that answered +// `/variants/paraphrase` locally and 401'd every chat turn against the real one -- a split that looks +// like a credential bug and is not. +// +// The flag is a developer seam, never set in a shipped build, and it does not change where +// credentials come from: a local console still wants a key it recognises. Importing Flag is safe from +// this leaf module because `flag/flag.ts` imports only `effect` -- the cycle this file avoids is +// through the plugin and event modules, which Flag does not touch. +export const CONSOLE_URL = (Flag.REDROB_CONSOLE_URL ?? "https://console.redrob.ai/api/backend/v1").replace( + /\/+$/, + "", +) export const CONSOLE_PACKAGE = "@ai-sdk/openai-compatible" // Every served console model publishes `capabilities.maxContextTokens: 1_000_000`. Held as one // constant because the value is uniform across the six ids, and used as the no-key fallback diff --git a/packages/core/src/variants.ts b/packages/core/src/variants.ts new file mode 100644 index 0000000000..8190cab44e --- /dev/null +++ b/packages/core/src/variants.ts @@ -0,0 +1,149 @@ +export * as Variants from "./variants" + +import { Effect, Schema } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" + +import { Variant } from "@redrob-code/schema/variant" + +import { ConsoleKey } from "./console-key" +import { CONSOLE_URL } from "./plugin/provider/redrob-constants" + +/** + * The console's multi-model endpoints, reached once on behalf of every client. + * + * `/v1/variants/paraphrase` and `/v1/variants/compare` are not OpenAI-standard, so they cannot arrive + * through the `@ai-sdk/openai-compatible` path. Putting them here rather than in each caller keeps one + * place that knows how to find the credential, what the console's envelope looks like, and how its + * refusals map -- three things that would otherwise drift between the CLI and the desktop apps. + * + * WHAT IS DELIBERATELY PASSED THROUGH RATHER THAN SUMMARISED: the per-slot `redrob` block, carrying + * `routedModel` and `costUsd`. When a request names `auto`, `routedModel` is the only thing that says which + * model actually answered, and one request here makes several charges -- so a caller that wants to show a + * user what was used and what it cost needs both, per slot, not a total. + */ + +/** No retries and no long wait: these are user-initiated and a caller can ask again. */ +const REQUEST_TIMEOUT = "120 seconds" + +export class NotConnected extends Error { + readonly _tag = "VariantsNotConnected" + constructor() { + super("the Redrob provider is not connected, so the console's multi-model endpoints are unavailable") + } +} + +export class Refused extends Error { + readonly _tag = "VariantsRefused" + constructor( + readonly status: number, + message: string, + /** Seconds the console asked the caller to wait, when it said. Surfaced so a UI can say when to retry. */ + readonly retryAfterSeconds?: number, + ) { + super(message) + } +} + +/** + * The console's own error envelope. + * + * Read rather than ignored because the message is the useful part: "This API key has spent $25.00 of its + * $25.00 monthly budget" tells a user what to do, and the status alone does not. A body that does not parse + * falls back to the status, which is still better than nothing. + */ +const ErrorEnvelope = Schema.Struct({ + message: Schema.String.pipe(Schema.optional), + error: Schema.Struct({ message: Schema.String.pipe(Schema.optional) }).pipe(Schema.optional), + retryAfterSeconds: Schema.Finite.pipe(Schema.optional), +}) + +function describe(status: number, body: string): { message: string; retryAfterSeconds?: number } { + const parsed = Schema.decodeUnknownOption(Schema.fromJsonString(ErrorEnvelope))(body) + if (parsed._tag === "None") return { message: `the console refused the request with ${status}` } + const value = parsed.value + return { + message: + value.error?.message ?? value.message ?? `the console refused the request with ${status}`, + retryAfterSeconds: value.retryAfterSeconds, + } +} + +const call = Effect.fn("Variants.call")(function* ( + path: string, + schema: Schema.Codec, + payload: A, +) { + const apiKey = yield* ConsoleKey.resolve() + /* + No key means the provider is not connected, which is a different thing from a rejected key: there is + nothing to ask. Callers turn this into a 404 rather than a 401, so a user on a local runtime is told the + feature is not available instead of being sent to check a credential they never set. + */ + if (!apiKey) return yield* Effect.fail(new NotConnected()) + + const http = yield* HttpClient.HttpClient + /* + Encoded THROUGH THE SCHEMA rather than handed to a generic JSON body: the request shape is declared once + and this is what holds the wire format to it, so a field renamed in the schema cannot silently keep + sending the old name. + */ + /* + `CONSOLE_URL` already carries the developer override and is already trailing-slash trimmed. Resolving + the flag a second time here is what let the override apply to this service alone while the session's + inference path kept talking to production. + */ + const request = yield* HttpClientRequest.post(`${CONSOLE_URL}${path}`).pipe( + HttpClientRequest.bearerToken(apiKey), + HttpClientRequest.schemaBodyJson(schema)(payload), + ) + + const response = yield* http.execute(request).pipe(Effect.timeout(REQUEST_TIMEOUT)) + const body = yield* response.text + + if (response.status < 200 || response.status >= 300) { + const { message, retryAfterSeconds } = describe(response.status, body) + /* + `Retry-After` is preferred over the body figure when both are present: it is the header HTTP clients + are built around, and the console sets it on exactly the statuses where waiting is the remedy. + */ + const header = response.headers["retry-after"] + const fromHeader = typeof header === "string" ? Number(header) : Number.NaN + return yield* Effect.fail( + new Refused( + response.status, + message, + Number.isFinite(fromHeader) ? fromHeader : retryAfterSeconds, + ), + ) + } + + const decoded = Schema.decodeUnknownOption(Schema.fromJsonString(Variant.Result))(body) + if (decoded._tag === "None") { + return yield* Effect.fail(new Refused(response.status, "could not read the console's response")) + } + return decoded.value +}) + +/* + The HTTP client is provided HERE rather than demanded from callers. + + Without this the `HttpClient` requirement propagated out of these two functions, through the route + handler, into the API's own requirement type, and out to every entry point that builds the API -- the + `serve` command failed to typecheck with `Type 'HttpClient' is not assignable to type 'Service'`, which + reads as a problem with `serve` and is not one. A leaf that makes one outbound call should not widen the + contract of everything above it. + + The repository's usual shape for this is a service node with `deps: () => [..., httpClient]`, which + shares one client process-wide. These are two plain functions rather than a service, so they take their + own fetch client instead. That is a real difference -- no shared connection pooling with the rest of the + CLI -- and it is proportionate for two endpoints called only on an explicit user action. Turning this + into a service node is the right move if it ever grows a third caller. +*/ +const withHttp = (effect: Effect.Effect) => + Effect.provide(effect, FetchHttpClient.layer) + +export const paraphrase = (request: Variant.ParaphraseRequest) => + withHttp(call("/variants/paraphrase", Variant.ParaphraseRequest, request)) + +export const compare = (request: Variant.CompareRequest) => + withHttp(call("/variants/compare", Variant.CompareRequest, request)) diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts index f58cd3128e..e7256237ca 100644 --- a/packages/protocol/src/api.ts +++ b/packages/protocol/src/api.ts @@ -4,6 +4,7 @@ import { SchemaErrorMiddleware } from "./middleware/schema-error" import { MessageGroup } from "./groups/message" import { ModelGroup } from "./groups/model" import { ProviderGroup } from "./groups/provider" +import { VariantGroup } from "./groups/variant" import { makeSessionGroup } from "./groups/session" import { makePermissionGroup } from "./groups/permission" import { FileSystemGroup } from "./groups/fs" @@ -42,6 +43,7 @@ const makeApiFromGroup = < .add(MessageGroup.middleware(sessionLocationMiddleware)) .add(ModelGroup.middleware(locationMiddleware)) .add(ProviderGroup.middleware(locationMiddleware)) + .add(VariantGroup.middleware(locationMiddleware)) .add(IntegrationGroup.middleware(locationMiddleware)) .add(CredentialGroup.middleware(locationMiddleware)) .add(makePermissionGroup(locationMiddleware, sessionLocationMiddleware)) diff --git a/packages/protocol/src/groups/variant.ts b/packages/protocol/src/groups/variant.ts new file mode 100644 index 0000000000..685c766071 --- /dev/null +++ b/packages/protocol/src/groups/variant.ts @@ -0,0 +1,56 @@ +import { Variant } from "@redrob-code/schema/variant" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { + InvalidRequestError, + ProviderNotFoundError, + ServiceUnavailableError, + UnauthorizedError, +} from "../errors" + +/** + * Asking several models the same thing. + * + * Exposed here rather than reached directly by each client, so the credential resolution, retries and + * error mapping exist once. The CLI's commands, the desktop apps, and anything else on the SDK all get the + * same behaviour, and the endpoints stay usable by a client that has no idea the console exists. + * + * `ProviderNotFoundError` is the refusal when the Redrob provider is not connected. That is a 404 about a + * provider rather than a 400 about the request, because the request was fine -- there is simply nothing + * here to serve it. A user on a local runtime or another vendor learns that immediately instead of seeing a + * credential error from a call that could never have worked. + */ +export const VariantGroup = HttpApiGroup.make("server.variant") + .add( + HttpApiEndpoint.post("variant.paraphrase", "/api/variant/paraphrase", { + payload: Variant.ParaphraseRequest, + success: Variant.Result, + error: [InvalidRequestError, UnauthorizedError, ProviderNotFoundError, ServiceUnavailableError], + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.variant.paraphrase", + summary: "Rewrite one text with several models", + description: + "Send the same text to two or more models and get each rewrite back, with the model that actually answered and what that slot cost.", + }), + ), + ) + .add( + HttpApiEndpoint.post("variant.compare", "/api/variant/compare", { + payload: Variant.CompareRequest, + success: Variant.Result, + error: [InvalidRequestError, UnauthorizedError, ProviderNotFoundError, ServiceUnavailableError], + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.variant.compare", + summary: "Answer one conversation with several models", + description: + "Send the same messages to two or more models and get each answer back, so a caller can offer them as a choice.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "variants", + description: "Experimental multi-model routes. Available while the Redrob provider is connected.", + }), + ) diff --git a/packages/redrob/src/session/processor.ts b/packages/redrob/src/session/processor.ts index ab583f0685..7a43dfaf17 100644 --- a/packages/redrob/src/session/processor.ts +++ b/packages/redrob/src/session/processor.ts @@ -640,7 +640,20 @@ const layer = Layer.effect( sessionID: ctx.assistantMessage.sessionID, error: ctx.assistantMessage.error, }) - yield* status.set(ctx.sessionID, { type: "idle" }) + /* + A refusal the user can act on ends as `blocked` rather than `idle`, so the client can offer the + page that fixes it instead of printing a sentence and leaving them to find it. `idle` otherwise, + which is every failure with nothing to click. + + Published BEFORE the error event would be wrong: a client that renders the error first and then + sees `idle` has already drawn the plain box. The order here -- error, then the terminal status -- + is the existing one, and `blocked` simply replaces `idle` in it. + */ + const block = SessionRetry.blocking(error, input.model.providerID) + yield* status.set( + ctx.sessionID, + block ? { type: "blocked", message: block.message, action: block.action } : { type: "idle" }, + ) }) const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) { diff --git a/packages/redrob/src/session/retry.ts b/packages/redrob/src/session/retry.ts index b687d2cc64..95da7b46a8 100644 --- a/packages/redrob/src/session/retry.ts +++ b/packages/redrob/src/session/retry.ts @@ -9,7 +9,22 @@ export type Err = ReturnType export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go" export const GO_UPSELL_URL = "https://code.redrob.ai/go" -export type RetryReason = "free_tier_limit" | "account_rate_limit" | (string & {}) +/** + * Where a console refusal sends the user. `/limits` shows the tier and its per-minute ceiling; + * `/api-keys` is where a key's monthly cap is raised; `/billing` is where the account is topped up. + * Each card links to the page that fixes ITS refusal, because a card that lands on the wrong page is + * barely better than no card. + */ +export const CONSOLE_LIMITS_URL = "https://console.redrob.ai/limits" +export const CONSOLE_KEYS_URL = "https://console.redrob.ai/api-keys" +export const CONSOLE_BILLING_URL = "https://console.redrob.ai/billing" +export type RetryReason = + | "free_tier_limit" + | "account_rate_limit" + | "console_rate_limit" + | "console_key_budget" + | "console_out_of_credit" + | (string & {}) export type Retryable = { message: string @@ -82,6 +97,107 @@ function exponential(attempt: number, random: number) { return Math.ceil(base + base * RETRY_JITTER_FACTOR * random) } +/** + * A console refusal the user can act on, as the card cowork already draws. + * + * Only for our own provider. `rate_limit_exceeded` and `insufficient_quota` are OpenAI's generic codes, + * so any vendor may send them -- offering a link to the Redrob console for somebody else's rate limit + * would send the user to a page that cannot fix their problem. + * + * Only the RATE refusal is here. A 402 is deliberately not retryable: the console chose that status over + * 429 precisely so clients would stop rather than turn one refusal into six, and a spinner reading + * "Retrying in 4s" over something that will never succeed is worse than a plain message. Those are + * handled by `blocking()` instead. + */ +function consoleRateLimit(error: SessionV1.APIError, provider: string): Retryable | undefined { + if (provider !== "redrob") return undefined + if (consoleErrorCode(error) !== "rate_limit_exceeded") return undefined + + /* + The console's own message already names the tier's per-minute ceiling and how long to wait, so it is + used as-is rather than paraphrased into something less specific. + */ + const message = error.data.message || "Rate limit reached" + return { + message, + action: { + reason: "console_rate_limit", + provider, + title: "Rate limit reached", + message: + "This is your workspace's requests-per-minute ceiling, which rises with your lifetime top-ups. It clears on its own; the console shows the current tier and limit.", + label: "open limits", + link: CONSOLE_LIMITS_URL, + }, + } +} + +/** The machine-readable code from the console's OpenAI-shaped error envelope, if there is one. */ +function consoleErrorCode(error: SessionV1.APIError): string | undefined { + const body = parseJSON(error.data.responseBody) + if (!isRecord(body)) return undefined + const envelope = body["error"] + if (!isRecord(envelope)) return undefined + const code = envelope["code"] + return typeof code === "string" && code.length > 0 ? code : undefined +} + +export type Blocking = { + message: string + action?: Retryable["action"] +} + +/** + * A console refusal that retrying cannot fix, but a person can. + * + * Both of the console's 402s land here. They are NOT routed through `retryable()` on purpose: the console + * answers 402 rather than 429 specifically so that clients stop, and a retry card would both retry and + * claim to be retrying something that will never succeed. + * + * The two are told apart by `code`, which is the only thing that distinguishes them on the + * OpenAI-compatible path -- the console's fuller refusal body, with `reason` and the figures, does not + * survive that envelope. They need different pages: a key over its cap is fixed by raising that key's + * cap, an empty balance by topping up, and sending the user to the wrong one wastes the card. + */ +export function blocking(error: Err, provider: string): Blocking | undefined { + if (provider !== "redrob") return undefined + if (!SessionV1.APIError.isInstance(error)) return undefined + const code = consoleErrorCode(error) + /* The console's own message carries the figures, so it is shown rather than paraphrased. */ + const message = error.data.message || "The request was refused" + + if (code === "api_key_budget_exhausted") { + return { + message, + action: { + reason: "console_key_budget", + provider, + title: "This key is over its monthly budget", + message: + "The cap is per API key and resets at the start of the month. Raise it on the key, or use a key without a cap.", + label: "open api keys", + link: CONSOLE_KEYS_URL, + }, + } + } + + if (code === "insufficient_quota") { + return { + message, + action: { + reason: "console_out_of_credit", + provider, + title: "The workspace is out of credit", + message: "Top up to continue. A balance covers every key on the workspace.", + label: "open billing", + link: CONSOLE_BILLING_URL, + }, + } + } + + return undefined +} + export function retryable(error: Err, provider: string) { // context overflow errors should not be retried if (SessionV1.ContextOverflowError.isInstance(error)) return undefined @@ -96,6 +212,12 @@ export function retryable(error: Err, provider: string) { !matchesRetryableMessage(error.data.responseBody) ) return undefined + /* + Checked before the upstream markers below, because those match on a body substring while this + matches on the console's own error code -- the more specific signal should win. + */ + const rate = consoleRateLimit(error, provider) + if (rate) return rate if (error.data.responseBody?.includes("FreeUsageLimitError")) { return { message: GO_UPSELL_MESSAGE, diff --git a/packages/redrob/test/server/httpapi-exercise/index.ts b/packages/redrob/test/server/httpapi-exercise/index.ts index ed925b3572..551550e90c 100644 --- a/packages/redrob/test/server/httpapi-exercise/index.ts +++ b/packages/redrob/test/server/httpapi-exercise/index.ts @@ -1740,6 +1740,37 @@ const scenarios: Scenario[] = [ }, "status", ), + /* + The variants routes, exercised through their NOT-CONNECTED path. + + The exerciser has no console credential, which is the honest state to assert here: a 404 saying the + Redrob provider is not connected. A scenario that needed a real key would either skip -- and this gate + fails on skip, correctly, because a skipped route is an unexercised route -- or send a paid request to + the live console on every CI run. + + 404 rather than 401 is the contract being pinned: nothing is configured to ask, so nothing refused us, + and a user on a local runtime should be told the feature is unavailable instead of being sent to check a + credential they never set. + */ + http.protected + .post("/api/variant/paraphrase", "variant.paraphrase") + .at((ctx) => ({ + path: "/api/variant/paraphrase", + headers: ctx.headers(), + body: { text: "exercise", models: [{ model: "auto" }] }, + })) + .json(404, object, "status"), + http.protected + .post("/api/variant/compare", "variant.compare") + .at((ctx) => ({ + path: "/api/variant/compare", + headers: ctx.headers(), + body: { + messages: [{ role: "user", content: "exercise" }], + models: [{ model: "auto" }, { model: "auto" }], + }, + })) + .json(404, object, "status"), http.protected .post("/global/upgrade", "global.upgrade") .global() diff --git a/packages/redrob/test/session/retry.test.ts b/packages/redrob/test/session/retry.test.ts index 702d5956f2..01f7bfd1ac 100644 --- a/packages/redrob/test/session/retry.test.ts +++ b/packages/redrob/test/session/retry.test.ts @@ -519,3 +519,72 @@ describe("session.message-v2.fromError", () => { }) }) }) + +/** + * The console's refusals, and the line between the two kinds. + * + * A 429 is retryable and gets the retry card. A 402 is NOT: the console answers 402 rather than 429 + * specifically so clients stop, so it must never appear in `retryable()` -- a retry card would both retry + * and claim to be retrying something that will never succeed. + * + * Both are gated on the provider being ours. `rate_limit_exceeded` and `insufficient_quota` are OpenAI's + * generic codes, so another vendor sending one must not be handed a link to our console. + */ +describe("session.retry console refusals", () => { + function consoleError(status: number, code: string, message = "refused"): SessionV1.APIError { + return Schema.decodeUnknownSync(SessionV1.APIError.Schema)( + new SessionV1.APIError({ + message, + isRetryable: status === 429, + statusCode: status, + responseBody: JSON.stringify({ error: { message, type: "x", param: null, code } }), + }).toObject(), + ) + } + + test("a rate refusal becomes a retry card pointing at the limits page", () => { + const result = SessionRetry.retryable(consoleError(429, "rate_limit_exceeded", "slow down"), "redrob") + expect(result?.message).toBe("slow down") + expect(result?.action?.reason).toBe("console_rate_limit") + expect(result?.action?.link).toBe(SessionRetry.CONSOLE_LIMITS_URL) + }) + + test("another vendor's rate limit gets no console link", () => { + const result = SessionRetry.retryable(consoleError(429, "rate_limit_exceeded"), "openai") + expect(result?.action).toBeUndefined() + }) + + test("a budget refusal is NOT retryable", () => { + expect(SessionRetry.retryable(consoleError(402, "api_key_budget_exhausted"), "redrob")).toBeUndefined() + }) + + test("a budget refusal blocks, pointing at the keys page where the cap lives", () => { + const result = SessionRetry.blocking(consoleError(402, "api_key_budget_exhausted", "over cap"), "redrob") + expect(result?.message).toBe("over cap") + expect(result?.action?.reason).toBe("console_key_budget") + expect(result?.action?.link).toBe(SessionRetry.CONSOLE_KEYS_URL) + }) + + test("an empty balance blocks, pointing at billing instead", () => { + const result = SessionRetry.blocking(consoleError(402, "insufficient_quota", "no credit"), "redrob") + expect(result?.action?.reason).toBe("console_out_of_credit") + expect(result?.action?.link).toBe(SessionRetry.CONSOLE_BILLING_URL) + }) + + test("the two 402s are told apart, which is the whole point of carrying the code", () => { + const budget = SessionRetry.blocking(consoleError(402, "api_key_budget_exhausted"), "redrob") + const balance = SessionRetry.blocking(consoleError(402, "insufficient_quota"), "redrob") + expect(budget?.action?.link).not.toBe(balance?.action?.link) + }) + + test("another vendor's 402 does not block with our pages", () => { + expect(SessionRetry.blocking(consoleError(402, "insufficient_quota"), "openai")).toBeUndefined() + }) + + test("a 402 with no code is left alone rather than guessed at", () => { + const error = Schema.decodeUnknownSync(SessionV1.APIError.Schema)( + new SessionV1.APIError({ message: "refused", isRetryable: false, statusCode: 402 }).toObject(), + ) + expect(SessionRetry.blocking(error, "redrob")).toBeUndefined() + }) +}) diff --git a/packages/schema/src/session-status-event.ts b/packages/schema/src/session-status-event.ts index f6a3022bcb..47cdec58e1 100644 --- a/packages/schema/src/session-status-event.ts +++ b/packages/schema/src/session-status-event.ts @@ -29,6 +29,32 @@ export const Info = Schema.Union([ Schema.Struct({ type: Schema.Literal("busy"), }), + /** + * The turn stopped and no retry will fix it, but there IS something the user can do. + * + * Distinct from `retry` because that variant means a retry is in flight: a client renders it with a + * spinner and a countdown, which would be a lie here. Distinct from a plain session error because + * those travel as message text and so can carry no link -- the user was told "budget exhausted" and + * left to find the page themselves. + * + * The console's 402s are the case this exists for. Both a key over its monthly cap and an account with + * an empty balance are refusals a person fixes in the console, and the console deliberately answers 402 + * rather than 429 so that clients STOP instead of turning one refusal into six. + */ + Schema.Struct({ + type: Schema.Literal("blocked"), + message: Schema.String, + action: optional( + Schema.Struct({ + reason: Schema.String, + provider: Schema.String, + title: Schema.String, + message: Schema.String, + label: Schema.String, + link: optional(Schema.String), + }), + ), + }), ]).annotate({ identifier: "SessionStatus" }) export type Info = Schema.Schema.Type diff --git a/packages/schema/src/variant.ts b/packages/schema/src/variant.ts new file mode 100644 index 0000000000..701a82177b --- /dev/null +++ b/packages/schema/src/variant.ts @@ -0,0 +1,86 @@ +export * as Variant from "./variant" + +import { Schema } from "effect" +import { optional } from "./schema" + +/** + * Asking several models the same thing, through the Redrob console. + * + * These are NOT OpenAI-standard endpoints, so they cannot arrive through the `@ai-sdk/openai-compatible` + * path every other model call uses. They are exposed here instead, which keeps ONE implementation for the + * three consumers that need it: the CLI's own commands, the desktop apps that read this server, and + * anything else built on the SDK. A second client path would mean re-implementing credential resolution, + * retries and error mapping, and those would drift. + * + * Available only while the Redrob provider is connected, because that is what the endpoints belong to. A + * user on a local runtime or another vendor gets a plain 404 rather than a confusing failure inside a + * request that could never have worked. + */ + +/** A model to ask, and how hard to make it think. */ +export const Model = Schema.Struct({ + model: Schema.String, + variant: Schema.String.pipe(optional), +}).annotate({ identifier: "Variant.Model" }) +export interface Model extends Schema.Schema.Type {} + +/** + * What the console reports about serving one slot. + * + * `routedModel` is the load-bearing field and the reason this is passed through rather than summarised: + * when the request names `auto`, it is the only thing that says which model actually answered. Without it + * the most a client can tell a user is "Redrob Auto did it", which is not the transparency the feature is + * for. `costUsd` is per slot, because one request here makes several charges. + */ +export const Provenance = Schema.Struct({ + requestId: Schema.String.pipe(optional), + routedModel: Schema.String.pipe(optional), + upstreamProvider: Schema.String.pipe(optional), + latencyMs: Schema.Finite.pipe(optional), + costUsd: Schema.Finite.pipe(optional), +}).annotate({ identifier: "Variant.Provenance" }) +export interface Provenance extends Schema.Schema.Type {} + +/** + * One slot's outcome. + * + * `text` and `error` are both optional and exactly one is expected, because the console runs the slots + * under `Promise.allSettled`: a request can come back with some slots served and others failed. Modelling + * that as an optional pair rather than a union keeps a partial result readable instead of forcing a client + * to discard the slots that did work. + */ +export const Slot = Schema.Struct({ + slot: Schema.Int, + model: Schema.String, + text: Schema.String.pipe(optional), + error: Schema.String.pipe(optional), + redrob: Provenance.pipe(optional), +}).annotate({ identifier: "Variant.Slot" }) +export interface Slot extends Schema.Schema.Type {} + +export const Result = Schema.Struct({ + variants: Schema.Array(Slot), + /** The sum the console already computed, so no client has to add the slots up itself. */ + totalCostUsd: Schema.Finite, +}).annotate({ identifier: "Variant.Result" }) +export interface Result extends Schema.Schema.Type {} + +export const ParaphraseRequest = Schema.Struct({ + text: Schema.String, + models: Schema.Array(Model), + requestId: Schema.String.pipe(optional), +}).annotate({ identifier: "Variant.ParaphraseRequest" }) +export interface ParaphraseRequest extends Schema.Schema.Type {} + +export const Message = Schema.Struct({ + role: Schema.Literals(["system", "user", "assistant"]), + content: Schema.String, +}).annotate({ identifier: "Variant.Message" }) +export interface Message extends Schema.Schema.Type {} + +export const CompareRequest = Schema.Struct({ + messages: Schema.Array(Message), + models: Schema.Array(Model), + requestId: Schema.String.pipe(optional), +}).annotate({ identifier: "Variant.CompareRequest" }) +export interface CompareRequest extends Schema.Schema.Type {} diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index aad9a38d9e..0b90a58f55 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -385,6 +385,12 @@ import type { V2SessionWaitResponses, V2SkillListErrors, V2SkillListResponses, + V2VariantCompareErrors, + V2VariantCompareResponses, + V2VariantParaphraseErrors, + V2VariantParaphraseResponses, + VariantCompareRequest, + VariantParaphraseRequest, VcsApplyErrors, VcsApplyResponses, VcsDiffErrors, @@ -5954,6 +5960,58 @@ export class Provider2 extends HeyApiClient { } } +export class Variant extends HeyApiClient { + /** + * Rewrite one text with several models + * + * Send the same text to two or more models and get each rewrite back, with the model that actually answered and what that slot cost. + */ + public paraphrase( + parameters: { + variantParaphraseRequest: VariantParaphraseRequest + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ key: "variantParaphraseRequest", map: "body" }] }]) + return (options?.client ?? this.client).post( + { + url: "/api/variant/paraphrase", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }, + ) + } + + /** + * Answer one conversation with several models + * + * Send the same messages to two or more models and get each answer back, so a caller can offer them as a choice. + */ + public compare( + parameters: { + variantCompareRequest: VariantCompareRequest + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ key: "variantCompareRequest", map: "body" }] }]) + return (options?.client ?? this.client).post({ + url: "/api/variant/compare", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + export class Connect extends HeyApiClient { /** * Connect with key @@ -7018,6 +7076,11 @@ export class V2 extends HeyApiClient { return (this._provider ??= new Provider2({ client: this.client })) } + private _variant?: Variant + get variant(): Variant { + return (this._variant ??= new Variant({ client: this.client })) + } + private _integration?: Integration get integration(): Integration { return (this._integration ??= new Integration({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e323c89178..ac9249b06d 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -358,6 +358,8 @@ export type AssistantMessage = { } summary?: boolean cost: number + routedModel?: string + upstreamProvider?: string tokens: { total?: number input: number @@ -691,6 +693,18 @@ export type SessionStatus = | { type: "busy" } + | { + type: "blocked" + message: string + action?: { + reason: string + provider: string + title: string + message: string + label: string + link?: string + } + } export type QuestionOption = { /** @@ -2017,6 +2031,8 @@ export type Config = { tail_turns?: number preserve_recent_tokens?: number reserved?: number + threshold?: number + maxTurnInputCostUsd?: number } experimental?: { disable_paste_summary?: boolean @@ -4126,6 +4142,8 @@ export type SessionMessageAssistant = { } finish?: string cost?: number + routedModel?: string + upstreamProvider?: string tokens?: { input: number output: number @@ -4875,6 +4893,49 @@ export type ProviderV2Info = { request: ProviderRequest } +export type VariantModel = { + model: string + variant?: string +} + +export type VariantParaphraseRequest = { + text: string + models: Array + requestId?: string +} + +export type VariantProvenance = { + requestId?: string + routedModel?: string + upstreamProvider?: string + latencyMs?: number + costUsd?: number +} + +export type VariantSlot = { + slot: number + model: string + text?: string + error?: string + redrob?: VariantProvenance +} + +export type VariantResult = { + variants: Array + totalCostUsd: number +} + +export type VariantMessage = { + role: "system" | "user" | "assistant" + content: string +} + +export type VariantCompareRequest = { + messages: Array + models: Array + requestId?: string +} + export type IntegrationWhen = { key: string op: "eq" | "neq" @@ -12160,6 +12221,80 @@ export type V2ProviderGetResponses = { export type V2ProviderGetResponse = V2ProviderGetResponses[keyof V2ProviderGetResponses] +export type V2VariantParaphraseData = { + body: VariantParaphraseRequest + path?: never + query?: never + url: "/api/variant/paraphrase" +} + +export type V2VariantParaphraseErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * ProviderNotFoundError + */ + 404: ProviderNotFoundError + /** + * ServiceUnavailableError + */ + 503: ServiceUnavailableError +} + +export type V2VariantParaphraseError = V2VariantParaphraseErrors[keyof V2VariantParaphraseErrors] + +export type V2VariantParaphraseResponses = { + /** + * Variant.Result + */ + 200: VariantResult +} + +export type V2VariantParaphraseResponse = V2VariantParaphraseResponses[keyof V2VariantParaphraseResponses] + +export type V2VariantCompareData = { + body: VariantCompareRequest + path?: never + query?: never + url: "/api/variant/compare" +} + +export type V2VariantCompareErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * ProviderNotFoundError + */ + 404: ProviderNotFoundError + /** + * ServiceUnavailableError + */ + 503: ServiceUnavailableError +} + +export type V2VariantCompareError = V2VariantCompareErrors[keyof V2VariantCompareErrors] + +export type V2VariantCompareResponses = { + /** + * Variant.Result + */ + 200: VariantResult +} + +export type V2VariantCompareResponse = V2VariantCompareResponses[keyof V2VariantCompareResponses] + export type V2IntegrationListData = { body?: never path?: never diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index 3a5e2e777e..2f7bb9e92d 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -2,6 +2,7 @@ import { Layer } from "effect" import { MessageHandler } from "./handlers/message" import { ModelHandler } from "./handlers/model" import { ProviderHandler } from "./handlers/provider" +import { VariantHandler } from "./handlers/variant" import { SessionHandler } from "./handlers/session" import { PermissionHandler } from "./handlers/permission" import { FileSystemHandler } from "./handlers/fs" @@ -26,6 +27,7 @@ export const handlers = Layer.mergeAll( MessageHandler, ModelHandler, ProviderHandler, + VariantHandler, IntegrationHandler, CredentialHandler, PermissionHandler, diff --git a/packages/server/src/handlers/variant.ts b/packages/server/src/handlers/variant.ts new file mode 100644 index 0000000000..1a1a653b8a --- /dev/null +++ b/packages/server/src/handlers/variant.ts @@ -0,0 +1,110 @@ +import { Variants } from "@redrob-code/core/variants" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" + +import { Api } from "../api" +import { + InvalidRequestError, + ProviderNotFoundError, + ServiceUnavailableError, + UnauthorizedError, +} from "@redrob-code/protocol/errors" + +/** + * The multi-model routes, and the one place the console's refusals become this API's errors. + * + * Mapping by STATUS rather than passing the console's status through, because the two APIs answer different + * questions. A 402 from the console means that workspace's key is over its budget -- to a caller of this + * server that is an authorization problem with the upstream account, not a payment this server is asking + * for. Forwarding 402 would invite a client to show a checkout it cannot complete. + * + * The console's MESSAGE is always carried through, because it is the part a person can act on: "This API + * key has spent $25.00 of its $25.00 monthly budget" says what to do and a bare status does not. + */ + +/** Why no key means 404 rather than 401: there is nothing configured to ask, so nothing refused us. */ +const notConnected = () => + new ProviderNotFoundError({ + providerID: "redrob", + message: + "The Redrob provider is not connected, so the multi-model endpoints are unavailable. Connect it with `redrob providers login`.", + }) + +function refusal(error: Variants.Refused) { + const status = error.status + if (status === 401 || status === 403) { + return new UnauthorizedError({ message: error.message }) + } + if (status === 402) { + /* + Out of credit, or a key over its monthly budget. Surfaced as unauthorized rather than as a payment + this server wants: the money is owed to the console by the account behind the key, and the caller of + this route may not be the person who can settle it. + */ + return new UnauthorizedError({ message: error.message }) + } + if (status === 429) { + /* + Rate limited. `ServiceUnavailableError` because the remedy is to wait, and the message already says + how long -- the console puts the figure in the sentence as well as in `Retry-After`. + */ + return new ServiceUnavailableError({ message: error.message }) + } + if (status >= 400 && status < 500) { + return new InvalidRequestError({ message: error.message }) + } + return new ServiceUnavailableError({ message: error.message }) +} + +/* + Generic over BOTH the error and requirement channels, because the service can fail in more ways than its + own two: the HTTP client contributes transport failures and the timeout contributes its own. Pinning those + away claimed they could not happen, and the type checker caught it -- which matters, because an unhandled + transport failure would have surfaced as an unmapped 500 with no message for the caller. +*/ +/** The union this route declares. Named so the catch below cannot be narrowed to its first branch. */ +type RouteError = + | InvalidRequestError + | UnauthorizedError + | ProviderNotFoundError + | ServiceUnavailableError + +const handle = (effect: Effect.Effect): Effect.Effect => + effect.pipe( + Effect.catch((error): Effect.Effect => { + if (error instanceof Variants.Refused) return Effect.fail(refusal(error)) + if (error instanceof Variants.NotConnected) return Effect.fail(notConnected()) + /* + Timeout, DNS failure, connection refused, a body that would not encode. All of them mean the console + could not be reached or answered, which is the caller's cue to try again rather than to change the + request -- so they are service-unavailable, and the underlying message is carried so the reason is + not lost. + */ + return Effect.fail( + new ServiceUnavailableError({ + message: + error instanceof Error + ? `could not reach the Redrob console: ${error.message}` + : "could not reach the Redrob console", + }), + ) + }), + ) + +export const VariantHandler = HttpApiBuilder.group(Api, "server.variant", (handlers) => + Effect.gen(function* () { + return handlers + .handle( + "variant.paraphrase", + Effect.fn(function* (ctx) { + return yield* handle(Variants.paraphrase(ctx.payload)) + }), + ) + .handle( + "variant.compare", + Effect.fn(function* (ctx) { + return yield* handle(Variants.compare(ctx.payload)) + }), + ) + }), +) diff --git a/packages/tui/src/feature-plugins/builtins.ts b/packages/tui/src/feature-plugins/builtins.ts index 1b7150a518..001f4d6749 100644 --- a/packages/tui/src/feature-plugins/builtins.ts +++ b/packages/tui/src/feature-plugins/builtins.ts @@ -11,6 +11,8 @@ import DiffViewer from "./system/diff-viewer" import Notifications from "./system/notifications" import PluginManager from "./system/plugins" import WhichKey from "./system/which-key" +import SessionLoop from "./session/loop" +import SessionVariants from "./session/variants" export type BuiltinTuiPlugin = Omit & { id: string @@ -32,5 +34,7 @@ export function createBuiltinPlugins(options: { experimentalEventSystem: boolean PluginManager, WhichKey, DiffViewer, + SessionLoop, + SessionVariants, ] } diff --git a/packages/tui/src/feature-plugins/session/loop.tsx b/packages/tui/src/feature-plugins/session/loop.tsx new file mode 100644 index 0000000000..125069bb65 --- /dev/null +++ b/packages/tui/src/feature-plugins/session/loop.tsx @@ -0,0 +1,450 @@ +import type { TuiPlugin, TuiPluginApi } from "@redrob-code/plugin/tui" + +import type { BuiltinTuiPlugin } from "../builtins" +import { kvTranslator } from "../../i18n" + +const id = "internal:loop" + +/** + * Autopilot: hold a GOAL and keep nudging toward it, one turn at a time, until it is met. + * + * The distinction that matters, and that the first version of this file got wrong: an autopilot does not + * re-send a fixed instruction. Re-sending the same sentence gives the model the same input every cycle, + * so it has no way to notice it is going in circles and no reason to change approach. What makes a loop + * useful is that each cycle knows the GOAL and knows WHAT HAS ALREADY BEEN TRIED. + * + * So each cycle sends three things: the goal, a ledger of approaches already rejected, and a request to + * assess where the work stands before taking the next step. The ledger is the part that earns its keep -- + * without it cycle N+1 walks into the wall cycle N just hit, and the loop's cost grows while its progress + * does not. + * + * TERMINAL ONLY. This is session control, not a product capability: nothing reaches the console, the loop + * itself spends no credit, and there is no API for it. Every cycle is an ORDINARY prompt, so it costs what + * typing the same thing would have cost and appears in usage as exactly that. + */ + +/** A backstop, not a target. A loop that reaches this did not finish; it ran out of rope. */ +const DEFAULT_MAX_CYCLES = 25 + +/** + * How many identical next-steps in a row count as stuck. + * + * Two is too eager -- a model legitimately repeats a step while waiting for something external, like a + * build. Three consecutive identical plans is not patience, it is a loop with no exit. + */ +const STALL_THRESHOLD = 3 + +/** + * What the model is asked to emit, and what this plugin reads back. + * + * Exact strings, because they are a contract between the prompt text and the parser. A model asked to + * "say when it is done" has no way to signal that in a form code can read, so it is given the words. + */ +export const LOOP_MARKERS = { + /** The goal is met. Requires the model to say HOW it knows, which is the next marker. */ + done: "AUTOPILOT: GOAL MET", + /** The model cannot proceed without a person -- a decision, a credential, an access it lacks. */ + blocked: "AUTOPILOT: BLOCKED", + /** Prefix for the one-line plan for this cycle. Repeated verbatim is what stall detection reads. */ + next: "AUTOPILOT NEXT:", + /** Prefix for an approach that failed, carried into later cycles so they do not retry it. */ + rejected: "AUTOPILOT REJECTED:", +} as const + +export type LoopLedger = { + /** The binding objective. Fixed for the life of the loop; the per-cycle instruction is derived from it. */ + goal: string + /** One line per approach the model reported as failed, so later cycles do not re-walk them. */ + rejected: string[] + /** The most recent plans, newest last. Only used to notice repetition. */ + recentNext: string[] +} + +type LoopState = { + sessionID: string + ledger: LoopLedger + cycles: number + maxCycles: number + /** Set while a cycle's prompt is in flight, so one completion cannot start two cycles. */ + sending: boolean + /** Accumulates this cycle's assistant text, which arrives in fragments. */ + buffer: string +} + +/** + * Whether the recent plans show the loop going in circles. + * + * Compared after normalising whitespace and case, because "Run the tests" and "run the tests." are the + * same plan and a loop that only notices byte-identical repetition notices nothing. + */ +export function isStalled(recentNext: readonly string[], threshold = STALL_THRESHOLD): boolean { + if (recentNext.length < threshold) return false + const tail = recentNext.slice(-threshold).map(line => line.trim().toLowerCase().replace(/\s+/g, " ").replace(/[.!]+$/, "")) + return tail.every(line => line.length > 0 && line === tail[0]) +} + +/** Pull the markers out of a cycle's reply. Tolerant of surrounding prose -- models add it. */ +export function readMarkers(text: string): { + done: boolean + blocked: boolean + next: string | undefined + rejected: string[] +} { + const lines = text.split("\n").map(line => line.trim()) + const after = (prefix: string) => + lines.filter(line => line.startsWith(prefix)).map(line => line.slice(prefix.length).trim()) + const nextLines = after(LOOP_MARKERS.next) + return { + done: text.includes(LOOP_MARKERS.done), + blocked: text.includes(LOOP_MARKERS.blocked), + /* The LAST plan wins: a model that revises mid-reply meant the revision. */ + next: nextLines.length > 0 ? nextLines[nextLines.length - 1] : undefined, + rejected: after(LOOP_MARKERS.rejected).filter(line => line.length > 0), + } +} + +/** + * The prompt for one cycle. + * + * Built fresh every time rather than stored, because its whole point is to carry state that has changed: + * the rejected list grows, and the cycle number moves. A stored instruction cannot do that. + */ +export function cyclePrompt(ledger: LoopLedger, cycle: number, maxCycles: number): string { + const parts: string[] = [ + `You are on autopilot. This is cycle ${cycle} of at most ${maxCycles}.`, + "", + "GOAL", + ledger.goal, + ] + + if (ledger.rejected.length > 0) { + parts.push( + "", + "ALREADY TRIED AND REJECTED -- do not repeat these:", + ...ledger.rejected.map(item => `- ${item}`), + ) + } + + parts.push( + "", + "Assess where the work stands against the GOAL, then take the single next step. Do the work; do not", + "just describe it.", + "", + "End your reply with these lines:", + ` ${LOOP_MARKERS.next} `, + ` ${LOOP_MARKERS.rejected} (only if you ruled one out)`, + "", + `When the GOAL is met, say ${LOOP_MARKERS.done} and state how you verified it.`, + `If you cannot proceed without a person -- a decision, a credential, an access you lack -- say`, + `${LOOP_MARKERS.blocked} and say what you need. Do not guess at it and do not keep trying.`, + ) + + return parts.join("\n") +} + + +/** + * Where a loop's ledger is kept between runs. + * + * Through `api.kv`, which is already written atomically under a lock to `paths.state/kv.json` -- so this + * needs no file handling of its own, and cannot race the rest of the TUI's persisted state. + * + * WHAT THIS ACTUALLY BUYS, stated precisely because it is easy to overclaim: the ledger lives in plugin + * memory, which context compaction does not touch, and `cyclePrompt` re-sends the rejected list every + * cycle, so the model already recovers it after a compaction. What is NOT survivable without this is the + * CLI exiting -- a crash, a restart, closing the terminal -- which today loses every approach the loop + * learned was a dead end. Restoring it means a resumed loop does not re-walk them. + */ +const LEDGER_KEY_PREFIX = "loop.ledger." + +function ledgerKey(sessionID: string): string { + return `${LEDGER_KEY_PREFIX}${sessionID}` +} + +function saveLedger(api: TuiPluginApi, sessionID: string, ledger: LoopLedger): void { + api.kv.set(ledgerKey(sessionID), ledger) +} + +function clearLedger(api: TuiPluginApi, sessionID: string): void { + /* Cleared rather than left behind: a finished goal's dead ends are not advice for the next goal. */ + api.kv.set(ledgerKey(sessionID), undefined) +} + +/** + * Read a stored ledger back, defensively. + * + * Anything on disk is untrusted input -- an older build wrote a different shape, a hand-edited file, a + * truncated write. A malformed ledger is treated as absent rather than crashing the loop or, worse, + * feeding the model a half-read list of things it must not retry. + */ +export function parseLedger(value: unknown): LoopLedger | undefined { + if (typeof value !== "object" || value === null) return undefined + const record = value as Record + if (typeof record.goal !== "string" || record.goal.trim().length === 0) return undefined + const strings = (input: unknown): string[] => + Array.isArray(input) ? input.filter((item): item is string => typeof item === "string") : [] + return { + goal: record.goal, + rejected: strings(record.rejected), + recentNext: strings(record.recentNext), + } +} + +/** + * The session the user is looking at, read from the route rather than from a "current session" accessor, + * because the plugin state exposes sessions by id and has no notion of which one is on screen. + */ +function activeSessionID(api: TuiPluginApi): string | undefined { + const route = api.route.current + if (route.name !== "session") return undefined + const sessionID = route.params?.sessionID + return typeof sessionID === "string" ? sessionID : undefined +} + +const tui: TuiPlugin = async (api) => { + /* + One loop per session. Sessions are independent conversations, and autopilot is a property of the thing + being driven -- running one goal in one session while asking a question in another is ordinary. + */ + const loops = new Map() + + const stop = (sessionID: string, reason: string, variant: "info" | "error" = "info") => { + const state = loops.get(sessionID) + if (!state) return false + loops.delete(sessionID) + clearLedger(api, sessionID) + api.ui.toast({ + title: kvTranslator(api.kv).t("loop.stopped.title"), + message: reason, + variant, + }) + return true + } + + const send = async (state: LoopState) => { + state.sending = true + state.buffer = "" + const text = cyclePrompt(state.ledger, state.cycles + 1, state.maxCycles) + try { + await api.client.session.prompt( + { sessionID: state.sessionID, parts: [{ type: "text", text }] }, + { throwOnError: true }, + ) + state.cycles += 1 + } catch (error) { + /* + A failed send ends the loop rather than retrying. The turn that would carry the work never started, + so retrying blind re-sends into a session whose state this plugin cannot see -- and a loop that + stops visibly is easier to recover from than one that silently doubles up. + */ + loops.delete(state.sessionID) + api.ui.toast({ + title: kvTranslator(api.kv).t("loop.failed.title"), + message: error instanceof Error ? error.message : String(error), + variant: "error", + }) + } finally { + state.sending = false + } + } + + /* Collect the reply as it streams, so the markers can be read once the turn is complete. */ + api.event.on("message.part.updated", (event) => { + const part = event.properties.part + if (part.type !== "text") return + const sessionID = event.properties.sessionID ?? part.sessionID + if (typeof sessionID !== "string") return + const state = loops.get(sessionID) + if (!state || typeof part.text !== "string") return + state.buffer = part.text + }) + + /* + `session.idle` is the completion signal: the turn is over and the model has produced the thing the next + cycle must react to. A timer would fire into a session still working, stacking turns on top of each + other -- expensive, and useless because the input the next cycle needs does not exist yet. + */ + api.event.on("session.idle", (event) => { + const sessionID = event.properties.sessionID + const state = loops.get(sessionID) + if (!state || state.sending) return + + const t = kvTranslator(api.kv) + const markers = readMarkers(state.buffer) + + if (markers.done) { + stop(sessionID, t.t("loop.stopped.done")) + return + } + + if (markers.blocked) { + /* + Stopping on BLOCKED is the point of having the marker. A loop that keeps prompting a model which has + said it needs a human decision burns cycles to re-learn the same thing, and buries the one message + the person actually needed to read. + */ + stop(sessionID, t.t("loop.stopped.blocked")) + return + } + + /* The ledger grows before the stall check, so a rejection recorded this cycle informs the next one. */ + let ledgerChanged = false + for (const item of markers.rejected) { + if (!state.ledger.rejected.includes(item)) { + state.ledger.rejected.push(item) + ledgerChanged = true + } + } + if (markers.next) { + state.ledger.recentNext.push(markers.next) + ledgerChanged = true + } + /* Written after each cycle, not only at the end: the cycle that crashes is the one worth remembering. */ + if (ledgerChanged) saveLedger(api, sessionID, state.ledger) + + if (isStalled(state.ledger.recentNext)) { + /* + Repeating one plan is how a loop looks when it has run out of ideas. Stopping hands it back to a + person while the transcript still shows what it kept trying, which is the useful moment to look. + */ + stop(sessionID, t.t("loop.stopped.stalled", { step: state.ledger.recentNext.at(-1) ?? "" })) + return + } + + if (state.cycles >= state.maxCycles) { + stop(sessionID, t.t("loop.stopped.exhausted", { max: String(state.maxCycles) })) + return + } + + void send(state) + }) + + /* A session that goes away takes its loop with it, so nothing points at a dead conversation. */ + api.event.on("session.deleted", (event) => { + loops.delete(event.properties.info.id) + }) + + api.event.on("session.error", (event) => { + const sessionID = event.properties.sessionID + if (typeof sessionID !== "string") return + /* + An error ends the loop. Whatever failed will keep failing, and re-prompting turns one visible error + into a stream of them. + */ + if (loops.has(sessionID)) stop(sessionID, kvTranslator(api.kv).t("loop.stopped.error"), "error") + }) + + api.keymap.registerLayer({ + commands: [ + { + name: "loop.start", + get title() { + return kvTranslator(api.kv).t("loop.start.title") + }, + category: "Session", + namespace: "palette", + run() { + const t = kvTranslator(api.kv) + const sessionID = activeSessionID(api) + if (!sessionID) { + api.ui.toast({ + title: t.t("loop.needs_session.title"), + message: t.t("loop.needs_session.message"), + variant: "error", + }) + return + } + + const existing = loops.get(sessionID) + if (existing) { + api.ui.toast({ + title: t.t("loop.already.title"), + message: t.t("loop.status", { + cycle: String(existing.cycles), + max: String(existing.maxCycles), + }), + variant: "info", + }) + return + } + + /* + A ledger left by a previous run means the CLI exited mid-goal. Its goal is offered back as the + prefilled value so resuming is one keypress, while still being a deliberate choice -- silently + restarting a loop the user may have abandoned is not a favour. + */ + const stored = parseLedger(api.kv.get(ledgerKey(sessionID))) + + api.ui.dialog.replace(() => ( + { + const goal = value.trim() + api.ui.dialog.clear() + if (!goal) return + + /* + The rejected list is carried over only when the goal is UNCHANGED. A different goal makes + the old dead ends irrelevant at best and misleading at worst -- they were dead ends for a + different question. + */ + const resumed = stored && stored.goal === goal ? stored : undefined + const state: LoopState = { + sessionID, + ledger: { + goal, + rejected: resumed ? [...resumed.rejected] : [], + /* Plans are NOT carried over: a stall is about one run's circling, not a resumed run's. */ + recentNext: [], + }, + cycles: 0, + maxCycles: DEFAULT_MAX_CYCLES, + sending: false, + buffer: "", + } + loops.set(sessionID, state) + /* + The first cycle goes immediately rather than waiting for an idle event: the user just + asked, and a loop that appears to do nothing until some later event is indistinguishable + from one that failed to start. + */ + void send(state) + }} + onCancel={() => api.ui.dialog.clear()} + /> + )) + }, + }, + { + name: "loop.stop", + get title() { + return kvTranslator(api.kv).t("loop.stop.title") + }, + category: "Session", + namespace: "palette", + run() { + const t = kvTranslator(api.kv) + const sessionID = activeSessionID(api) + if (!sessionID) return + if (!stop(sessionID, t.t("loop.stopped.user"))) { + api.ui.toast({ + title: t.t("loop.none.title"), + message: t.t("loop.none.message"), + variant: "info", + }) + } + }, + }, + ], + bindings: api.tuiConfig.keybinds.gather("loop.palette", ["loop.start", "loop.stop"]), + }) +} + +const plugin: BuiltinTuiPlugin = { + id, + tui, +} + +export default plugin diff --git a/packages/tui/src/feature-plugins/session/variants.tsx b/packages/tui/src/feature-plugins/session/variants.tsx new file mode 100644 index 0000000000..131df55cdd --- /dev/null +++ b/packages/tui/src/feature-plugins/session/variants.tsx @@ -0,0 +1,377 @@ +import type { TuiPlugin, TuiPluginApi } from "@redrob-code/plugin/tui" + +import type { BuiltinTuiPlugin } from "../builtins" +import { kvTranslator } from "../../i18n" + +const id = "internal:variants" + +/** + * `paraphrase` and `compare`, and the one thing they must both do: say what was used and what it cost. + * + * Both go through this server's own `/api/variant/*` routes rather than reaching the console directly, so + * credential resolution and error mapping live in one place. The routes answer 404 when the Redrob provider + * is not connected, which is what gates these commands -- a user on a local runtime or another vendor is + * told the feature is unavailable instead of watching a request fail for reasons that look like their fault. + * + * WHY THE COST IS ALWAYS SHOWN. One of these requests makes SEVERAL charges -- one per model. A user who + * thinks they made one request will be surprised by the bill unless the surprise happens immediately, at the + * moment they can still decide not to do it again. So every result prints the model that actually answered, + * per slot, with its own cost and the total. + */ + +/** Two is the minimum that makes a comparison, and the console refuses fewer. */ +const COMPARE_MINIMUM = 2 + +export type SlotResult = { + slot: number + model: string + text?: string + error?: string + redrob?: { + routedModel?: string + upstreamProvider?: string + latencyMs?: number + costUsd?: number + } +} + +export type VariantResult = { + variants: SlotResult[] + totalCostUsd: number +} + +/** + * What a slot is called when shown to a person. + * + * `routedModel` wins over the requested id, because when the request said `auto` the requested id tells the + * reader nothing they did not already type. Both are shown when they differ, since "I asked for auto and got + * this" is the interesting fact. + */ +export function describeSlot(slot: SlotResult): string { + const routed = slot.redrob?.routedModel + if (routed && routed !== slot.model) return `${slot.model} → ${routed}` + return routed ?? slot.model +} + +/** Dollars at a fixed width, small enough not to round a fraction of a cent to nothing. */ +function money(value: number | undefined): string { + return typeof value === "number" ? `$${value.toFixed(4)}` : "—" +} + +/** + * The cost table. + * + * Per slot AND totalled, because the total alone hides that a slot failed for free while another was + * charged, and the slots alone make a reader do the addition the response already did. + */ +export function costReport(result: VariantResult): string { + const lines = result.variants.map((slot) => { + const latency = slot.redrob?.latencyMs + const timing = typeof latency === "number" ? `${Math.round(latency)}ms` : "—" + const status = slot.error ? `failed: ${slot.error}` : money(slot.redrob?.costUsd) + return ` ${describeSlot(slot).padEnd(34)} ${status.padStart(12)} ${timing.padStart(8)}` + }) + lines.push(` ${"total".padEnd(34)} ${money(result.totalCostUsd).padStart(12)}`) + return lines.join("\n") +} + +/** The served slots, in order. A failed slot is not an option a user can pick. */ +export function servedSlots(result: VariantResult): SlotResult[] { + return result.variants.filter((slot) => typeof slot.text === "string" && slot.text.length > 0) +} + +/** + * A one-line preview for the selection list. + * + * An answer is usually several lines and the list renders one, so the first non-empty line is shown and the + * rest is what the user gets when they choose. Truncated rather than wrapped, because a list row that grows + * pushes the other options off the screen -- which is the opposite of what a chooser needs. + */ +export function previewOf(text: string, width = 72): string { + const firstLine = text.split("\n").find((line) => line.trim().length > 0)?.trim() ?? "" + if (firstLine.length <= width) return firstLine + return `${firstLine.slice(0, width - 1)}…` +} + +function activeSessionID(api: TuiPluginApi): string | undefined { + const route = api.route.current + if (route.name !== "session") return undefined + const sessionID = route.params?.sessionID + return typeof sessionID === "string" ? sessionID : undefined +} + +/** + * The models to fan out to. + * + * Taken from what the catalogue says is connected rather than hard-coded, so a deployment that serves + * different ids does not need this file changed. `auto` is excluded: fanning out to the router twice would + * be two charges for what may well be the same model, which is the opposite of the point. + */ +function fanOutModels(api: TuiPluginApi, count: number): string[] { + const redrob = api.state.provider.find((provider) => provider.id === "redrob") + if (!redrob) return [] + return Object.keys(redrob.models ?? {}) + .filter((model) => model !== "auto") + .slice(0, count) +} + +const tui: TuiPlugin = async (api) => { + const connected = () => api.state.provider.some((provider) => provider.id === "redrob") + + const requireRedrob = (): boolean => { + if (connected()) return true + const t = kvTranslator(api.kv) + api.ui.toast({ + title: t.t("variants.unavailable.title"), + message: t.t("variants.unavailable.message"), + variant: "info", + }) + return false + } + + /** + * Cost first, then the content: the number is the part a user did not ask for and must not miss. + * + * A DIALOG rather than a toast, which is where this started. The toast is absolutely positioned at the + * top-right, capped at sixty columns, and dismisses itself on a timer -- pressing the command for real + * showed it clipping the cost total and the whole answer, keeping only the first few slot rows. The two + * things a user most needs were the two it dropped. A dialog is dismissed by the person reading it, so + * nothing disappears before it has been read. + */ + const report = (title: string, result: VariantResult, body?: string) => { + api.ui.dialog.replace(() => ( + api.ui.dialog.clear()} + /> + )) + } + + api.keymap.registerLayer({ + commands: [ + { + name: "variants.paraphrase", + get title() { + return kvTranslator(api.kv).t("variants.paraphrase.title") + }, + category: "Session", + namespace: "palette", + run() { + const t = kvTranslator(api.kv) + if (!requireRedrob()) return + + api.ui.dialog.replace(() => ( + { + const text = value.trim() + api.ui.dialog.clear() + if (!text) return + + const models = fanOutModels(api, 3) + if (models.length === 0) { + api.ui.toast({ + title: t.t("variants.no_models.title"), + message: t.t("variants.no_models.message"), + variant: "error", + }) + return + } + + void api.client.v2.variant + .paraphrase( + { + variantParaphraseRequest: { + text, + models: models.map((model) => ({ model })), + }, + }, + { throwOnError: true }, + ) + .then(({ data: result }) => { + const served = servedSlots(result) + if (served.length === 0) { + /* + Every slot failed. The cost report is still shown, because a failed slot can still + have been charged and a silent zero would be a claim rather than a fact. + */ + report(t.t("variants.none.title"), result) + return + } + /* + Paraphrase returns ONE value to the user: the last served slot, which is the end of the + chain the models were asked to refine. The others are not offered as a choice -- that is + what `compare` is for -- but every one of them is named in the cost table, so the user + can see what the single answer cost to produce. + */ + const chosen = served[served.length - 1] + report(t.t("variants.paraphrase.done"), result, chosen.text) + }) + .catch((error: unknown) => { + api.ui.toast({ + title: t.t("variants.failed.title"), + message: error instanceof Error ? error.message : String(error), + variant: "error", + }) + }) + }} + onCancel={() => api.ui.dialog.clear()} + /> + )) + }, + }, + { + name: "variants.compare", + get title() { + return kvTranslator(api.kv).t("variants.compare.title") + }, + category: "Session", + namespace: "palette", + run() { + const t = kvTranslator(api.kv) + if (!requireRedrob()) return + + const sessionID = activeSessionID(api) + if (!sessionID) { + api.ui.toast({ + title: t.t("variants.needs_session.title"), + message: t.t("variants.needs_session.message"), + variant: "error", + }) + return + } + + api.ui.dialog.replace(() => ( + { + const question = value.trim() + api.ui.dialog.clear() + if (!question) return + + const models = fanOutModels(api, COMPARE_MINIMUM) + if (models.length < COMPARE_MINIMUM) { + api.ui.toast({ + title: t.t("variants.no_models.title"), + message: t.t("variants.no_models.message"), + variant: "error", + }) + return + } + + void api.client.v2.variant + .compare( + { + variantCompareRequest: { + messages: [{ role: "user", content: question }], + models: models.map((model) => ({ model })), + }, + }, + { throwOnError: true }, + ) + .then(({ data: result }) => { + const served = servedSlots(result) + if (served.length === 0) { + report(t.t("variants.none.title"), result) + return + } + + /* + Cost BEFORE the choice, and the choice only once the cost has been acknowledged. + These are two dialogs on one stack, so opening the chooser directly would replace the + cost dialog the instant it appeared -- which is the same disappearing-evidence problem + the toast had. Confirming the cost is what opens the chooser. + */ + api.ui.dialog.replace(() => ( + { + /* + Deferred by a tick on purpose. `DialogAlert` calls this and then clears the + dialog stack itself, so a chooser pushed synchronously here is opened and then + immediately wiped by that clear -- which is what happened the first time this + was driven by hand: the cost dialog closed and nothing replaced it. Queuing the + push means it lands after the clear rather than before it. + */ + setTimeout(() => { + api.ui.dialog.replace(() => ( + + title={t.t("variants.compare.choose")} + options={served.map((slot, index) => ({ + title: `${describeSlot(slot)} · ${money(slot.redrob?.costUsd)}`, + value: index, + description: previewOf(slot.text ?? ""), + }))} + onSelect={(option) => { + api.ui.dialog.clear() + const chosen = served[option.value] + if (!chosen?.text) return + void api.client.v2.session + .prompt( + { + sessionID, + prompt: { + text: [ + question, + "", + `(answered by ${describeSlot(chosen)})`, + "", + chosen.text, + ].join("\n"), + }, + }, + { throwOnError: true }, + ) + /* + Reported rather than swallowed. Without this the first hand-run looked + like the pick had simply done nothing: the dialog closed, the answer + never arrived, and the reason was discarded with the rejected promise. + A user who has just been charged for several models must be told when + the thing they paid for failed to land. + */ + .catch((error: unknown) => { + api.ui.toast({ + title: t.t("variants.insert_failed.title"), + message: error instanceof Error ? error.message : String(error), + variant: "error", + }) + }) + }} + /> + )) + }, 0) + }} + /> + )) + }) + .catch((error: unknown) => { + api.ui.toast({ + title: t.t("variants.failed.title"), + message: error instanceof Error ? error.message : String(error), + variant: "error", + }) + }) + }} + onCancel={() => api.ui.dialog.clear()} + /> + )) + }, + }, + ], + bindings: api.tuiConfig.keybinds.gather("variants.palette", [ + "variants.paraphrase", + "variants.compare", + ]), + }) +} + +const plugin: BuiltinTuiPlugin = { + id, + tui, +} + +export default plugin diff --git a/packages/tui/src/i18n/en.ts b/packages/tui/src/i18n/en.ts index 7f3e2798e8..dc989d7d89 100644 --- a/packages/tui/src/i18n/en.ts +++ b/packages/tui/src/i18n/en.ts @@ -738,6 +738,39 @@ export const dict = { "mcp.status.failed": "failed", // Plugin manager + "loop.status": "Cycle {cycle} of at most {max}", + "loop.start.title": "Start autopilot", + "loop.stop.title": "Stop the loop", + "loop.stopped.title": "Loop stopped", + "loop.stopped.done": "The goal was reported met.", + "loop.stopped.user": "Stopped at your request.", + "loop.stopped.error": "The session failed, so the loop stopped rather than re-prompting a broken session.", + "loop.stopped.exhausted": "Reached the limit of {max} cycles without reporting it was finished.", + "loop.failed.title": "Loop could not continue", + "loop.needs_session.title": "No session", + "loop.needs_session.message": "Open a session first; a loop runs inside one.", + "loop.already.title": "A loop is already running here", + "loop.none.title": "No loop here", + "loop.none.message": "This session has no loop to stop.", + "loop.start.placeholder": "The goal — include how you will know it is met", + "loop.stopped.blocked": "It needs a person: a decision, a credential, or an access it does not have.", + "loop.stopped.stalled": "Stopped going in circles — it planned the same step three times: {step}", + "variants.paraphrase.title": "Rewrite with several models", + "variants.paraphrase.placeholder": "The text to rewrite", + "variants.paraphrase.done": "Rewritten", + "variants.compare.title": "Compare answers from several models", + "variants.compare.placeholder": "The question to ask each model", + "variants.compare.done": "Answers in", + "variants.compare.choose": "Pick an answer to keep", + "variants.unavailable.title": "Not available", + "variants.unavailable.message": "These need the Redrob provider. Connect it with `redrob providers login`.", + "variants.no_models.title": "Nothing to fan out to", + "variants.no_models.message": "The Redrob provider is serving no models this can use.", + "variants.needs_session.title": "No session", + "variants.needs_session.message": "Open a session first; the answer you pick goes into it.", + "variants.none.title": "Every model failed", + "variants.failed.title": "The request failed", + "variants.insert_failed.title": "The answer could not be added", "plugins.dialog.title": "Plugins", "plugins.install.title": "Install plugin", "plugins.install.placeholder": "npm package name", diff --git a/packages/tui/src/i18n/ko.ts b/packages/tui/src/i18n/ko.ts index e72e28e24c..211ab55600 100644 --- a/packages/tui/src/i18n/ko.ts +++ b/packages/tui/src/i18n/ko.ts @@ -707,6 +707,39 @@ export const dict: Record = { "mcp.status.disabled": "사용 안 함", "mcp.status.failed": "실패", + "loop.status": "{max}회 중 {cycle}회 진행", + "loop.start.title": "오토파일럿 시작", + "loop.stop.title": "반복 중지", + "loop.stopped.title": "반복 중지됨", + "loop.stopped.done": "목표가 충족됐다고 보고했습니다.", + "loop.stopped.user": "요청에 따라 중지했습니다.", + "loop.stopped.error": "세션이 실패해서, 망가진 세션에 다시 요청하지 않고 반복을 멈췄습니다.", + "loop.stopped.exhausted": "완료를 알리지 않은 채 {max}회 한도에 도달했습니다.", + "loop.failed.title": "반복을 이어갈 수 없습니다", + "loop.needs_session.title": "세션이 없습니다", + "loop.needs_session.message": "먼저 세션을 여세요. 반복은 세션 안에서 돕니다.", + "loop.already.title": "이미 이 세션에서 반복이 돌고 있습니다", + "loop.none.title": "반복이 없습니다", + "loop.none.message": "이 세션에는 중지할 반복이 없습니다.", + "loop.start.placeholder": "목표 — 무엇으로 충족을 판단할지 함께 적으세요", + "loop.stopped.blocked": "사람이 필요합니다: 결정, 자격증명, 또는 갖지 못한 접근 권한.", + "loop.stopped.stalled": "같은 단계를 세 번 계획해서 멈췄습니다: {step}", + "variants.paraphrase.title": "여러 모델로 다시 쓰기", + "variants.paraphrase.placeholder": "다시 쓸 텍스트", + "variants.paraphrase.done": "다시 썼습니다", + "variants.compare.title": "여러 모델의 답변 비교", + "variants.compare.placeholder": "각 모델에 물어볼 질문", + "variants.compare.done": "답변이 도착했습니다", + "variants.compare.choose": "남길 답변을 고르세요", + "variants.unavailable.title": "사용할 수 없습니다", + "variants.unavailable.message": "레드롭 프로바이더가 필요합니다. `redrob providers login` 으로 연결하세요.", + "variants.no_models.title": "호출할 모델이 없습니다", + "variants.no_models.message": "레드롭 프로바이더가 이 기능에 쓸 모델을 서비스하지 않습니다.", + "variants.needs_session.title": "세션이 없습니다", + "variants.needs_session.message": "먼저 세션을 여세요. 고른 답변이 그 세션에 들어갑니다.", + "variants.none.title": "모든 모델이 실패했습니다", + "variants.failed.title": "요청이 실패했습니다", + "variants.insert_failed.title": "답변을 넣지 못했습니다", "plugins.dialog.title": "플러그인", "plugins.install.title": "플러그인 설치", "plugins.install.placeholder": "npm 패키지 이름", diff --git a/packages/tui/test/loop.test.ts b/packages/tui/test/loop.test.ts new file mode 100644 index 0000000000..5664908559 --- /dev/null +++ b/packages/tui/test/loop.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, test } from "bun:test" +import { LOOP_MARKERS, cyclePrompt, isStalled, parseLedger, readMarkers } from "../src/feature-plugins/session/loop" + +/* + What is worth testing here is the part that makes this an autopilot rather than a re-sender: the markers + are a contract between the prompt text and the parser, and the stall detector is the only thing that + notices a loop going in circles. Both fail SILENTLY when wrong -- a loop that never stops looks like a + loop that is working, right up until the cycle cap. +*/ + +describe("readMarkers", () => { + test("reads a completion claim out of surrounding prose", () => { + const reply = `I ran the suite and it is green.\n\n${LOOP_MARKERS.done} — verified by 317 passing tests.` + expect(readMarkers(reply).done).toBe(true) + }) + + test("reads a block, which must stop the loop rather than be retried", () => { + const reply = `I cannot reach the production database.\n${LOOP_MARKERS.blocked} I need MIGRATION_DATABASE_URL.` + const markers = readMarkers(reply) + expect(markers.blocked).toBe(true) + expect(markers.done).toBe(false) + }) + + test("takes the LAST plan when a reply revises itself", () => { + // A model that changes its mind mid-reply meant the revision, not the first thought. + const reply = [ + `${LOOP_MARKERS.next} add the failing test`, + "on reflection the test already exists", + `${LOOP_MARKERS.next} fix the Windows branch in _safe_chmod`, + ].join("\n") + expect(readMarkers(reply).next).toBe("fix the Windows branch in _safe_chmod") + }) + + test("collects every rejected approach, since they all have to reach the next cycle", () => { + const reply = [ + `${LOOP_MARKERS.rejected} patching the caller instead of the helper`, + `${LOOP_MARKERS.rejected} widening the type to silence the checker`, + `${LOOP_MARKERS.next} fix the helper`, + ].join("\n") + expect(readMarkers(reply).rejected).toEqual([ + "patching the caller instead of the helper", + "widening the type to silence the checker", + ]) + }) + + test("a reply with no markers yields nothing rather than guessing", () => { + const markers = readMarkers("I had a look around and things seem fine.") + expect(markers.done).toBe(false) + expect(markers.blocked).toBe(false) + expect(markers.next).toBeUndefined() + expect(markers.rejected).toEqual([]) + }) +}) + +describe("isStalled", () => { + test("three identical plans in a row is a loop with no exit", () => { + expect(isStalled(["run the tests", "run the tests", "run the tests"])).toBe(true) + }) + + test("two is patience, not a stall", () => { + // A model legitimately repeats a step while waiting on something external, like a build. + expect(isStalled(["run the tests", "run the tests"])).toBe(false) + }) + + test("ignores punctuation, case and spacing, which are not a change of plan", () => { + expect(isStalled(["Run the tests", "run the tests.", "RUN THE TESTS"])).toBe(true) + }) + + test("progress resets it", () => { + expect(isStalled(["run the tests", "run the tests", "fix the failing assertion"])).toBe(false) + }) + + test("blank plans are not treated as repetition", () => { + // Otherwise a model that simply stopped emitting the marker would read as stuck on an empty step. + expect(isStalled(["", "", ""])).toBe(false) + }) +}) + +describe("cyclePrompt", () => { + test("carries the goal and the cycle position", () => { + const prompt = cyclePrompt({ goal: "make CI green", rejected: [], recentNext: [] }, 3, 25) + expect(prompt).toContain("make CI green") + expect(prompt).toContain("cycle 3 of at most 25") + }) + + test("carries rejected approaches, which is the whole reason cycles differ", () => { + /* + Without this the loop is a re-sender: cycle N+1 gets the same input as cycle N and walks into the + same wall. This assertion is the one that would catch a regression back to that behaviour. + */ + const prompt = cyclePrompt( + { goal: "make CI green", rejected: ["bumping the timeout"], recentNext: [] }, + 2, + 25, + ) + expect(prompt).toContain("ALREADY TRIED AND REJECTED") + expect(prompt).toContain("bumping the timeout") + }) + + test("omits the rejected section entirely when there is nothing to say", () => { + const prompt = cyclePrompt({ goal: "make CI green", rejected: [], recentNext: [] }, 1, 25) + expect(prompt).not.toContain("ALREADY TRIED AND REJECTED") + }) + + test("asks for the markers it will later parse", () => { + // The prompt and the parser have to agree; this is what keeps them from drifting apart. + const prompt = cyclePrompt({ goal: "g", rejected: [], recentNext: [] }, 1, 5) + expect(prompt).toContain(LOOP_MARKERS.next) + expect(prompt).toContain(LOOP_MARKERS.done) + expect(prompt).toContain(LOOP_MARKERS.blocked) + expect(prompt).toContain(LOOP_MARKERS.rejected) + }) +}) + +describe("parseLedger", () => { + /* + Anything read back from disk is untrusted: an older build wrote a different shape, a file was + hand-edited, a write was truncated. The failure to avoid is feeding the model a half-read list of + things it must not retry -- it would then retry them, or avoid things it never tried. + */ + test("accepts a well-formed ledger", () => { + expect( + parseLedger({ goal: "make CI green", rejected: ["bump the timeout"], recentNext: ["run tests"] }), + ).toEqual({ goal: "make CI green", rejected: ["bump the timeout"], recentNext: ["run tests"] }) + }) + + test("refuses anything without a real goal, since the goal IS the loop", () => { + expect(parseLedger(undefined)).toBeUndefined() + expect(parseLedger(null)).toBeUndefined() + expect(parseLedger("make CI green")).toBeUndefined() + expect(parseLedger({ rejected: ["x"] })).toBeUndefined() + expect(parseLedger({ goal: "" })).toBeUndefined() + expect(parseLedger({ goal: " " })).toBeUndefined() + }) + + test("drops non-string entries instead of rejecting the whole ledger", () => { + // One bad entry should cost that entry, not the goal and every other dead end alongside it. + expect(parseLedger({ goal: "g", rejected: ["keep", 42, null, "also keep"] })).toEqual({ + goal: "g", + rejected: ["keep", "also keep"], + recentNext: [], + }) + }) + + test("tolerates missing lists rather than requiring them", () => { + expect(parseLedger({ goal: "g" })).toEqual({ goal: "g", rejected: [], recentNext: [] }) + }) + + test("a list that is not a list becomes empty, not a crash", () => { + expect(parseLedger({ goal: "g", rejected: "bump the timeout" })).toEqual({ + goal: "g", + rejected: [], + recentNext: [], + }) + }) +})