Skip to content

fix(client): retry a lost create only where the server deduplicates - #5363

Merged
neubig merged 3 commits into
mainfrom
fix/typescript-create-retry-capability
Oct 3, 2026
Merged

neubig merged 3 commits into
mainfrom
fix/typescript-create-retry-capability

Conversation

@neubig

@neubig neubig commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

HUMAN:

I've performed agent-driven testing of this.


AGENT:

Why

ConversationClient.createConversation surfaces whatever the transport throws. When the response is lost — timeout, dropped connection — the caller cannot tell whether the create was applied. The conversation may exist with its initial_message already executing.

#5035 works around this in the caller with a bounded GET /api/conversations/{id} polling loop, and #5036 patches the client to do the same reconciliation internally. Both are symptom-level: neither makes re-sending safe, so both read to recover a value the server is the only authority on, and both carry a new timeout knob (creationRecoveryTimeout) to bound it.

Summary

The actual fix for the underlying race is server-side (#5362, in this same series). With it, a re-sent create is deduplicated under the conversation's lifecycle lock: exactly one create applies, the other gets the existing conversation back, and initial_message runs once.

So the client-side need is only: re-send, where that is safe.

  • Re-send once on a transport error.
  • Only when the server advertises idempotent_conversation_create_v1 (added in fix(server): dedupe concurrent conversation creates and forks #5362).
  • Only when the caller supplied a conversation_id to deduplicate on — without one there is no stable key, and re-sending would create a second conversation.
  • A server that cannot be probed is treated as unsupported, so an older backend keeps surfacing the transport error rather than risking a duplicate.

An HttpError is never retried: a real HTTP response means the server's answer is authoritative, and validation failures must surface, not be papered over.

This needs no new option, and no polling: /server_info is already fetched and cached per HttpClient.

How to Test

src/__tests__/conversation-create-retry.test.ts drives a real HTTP server, as #5036's tests did — the failure mode is a lost response, which a stubbed fetch would not exercise.

  • a lost create is retried, and the re-send is deduplicated rather than applied twice (duplicates === 0);
  • against a server without the capability the create is not replayed — this test fails if the capability gate is removed, which I verified by disabling the gate;
  • no replay when the caller supplied no conversation_id;
  • a 400 is not concealed.

clients/typescript: 334 passed (21 files). tsc --noEmit, eslint, prettier clean.

Notes

This supersedes #5036, which I plan to close. #5036's diagnosis is correct and its test harness is a good one; the difference is the remedy. Its reconciliation loop recovers a value instead of making the operation idempotent, so it works around the server rather than fixing it, and it does not make a retry safe on a server that lacks the fix. With #5362 landed, the simpler retry is both smaller and strictly more correct.


This PR was created by an AI agent (OpenHands) on behalf of @neubig.

Issue Number

Fixes #5035.

Type

  • Bug fix

Notes

Depends on #5362: the retry is gated on the capability that PR advertises, so
on a server without it this change is inert and behavior is unchanged.

Live main-vs-PR validation (revalidated at current head after the main merge)

Exact SHAs

Setup: there were four real agent-servers and two compiled TypeScript clients. The harness imports clients/typescript/dist/clients.js and dist/index.js from each tree, after npm ci && npm run build. A response-drop proxy sat in front of each server. It forwards POST /api/conversations to the real server and waits for the committed upstream response. It then resets the client's TCP connection (SO_LINGER 0) without sending any bytes. It can also answer the next N GET /server_info requests with HTTP 503. Every result below was checked against the server directly, not through the proxy: GET /api/conversations/{id}, /count, and the user MessageEvents from /events/search.

git worktree add --detach /tmp/v5363/wt-base 5ffdd2933c51423e302f5ee5e5b66df0ad9cc28a
git worktree add --detach /tmp/v5363/wt-base-dedup 5ffdd2933c51423e302f5ee5e5b66df0ad9cc28a
git worktree add --detach /tmp/v5363/wt-head-dedup 575511fec4bc2e21d29a45db8c7f5cab2f3dd88b
git -C /tmp/v5363/wt-base-dedup merge --no-edit 4cd079abe4c3df906d2ae968729b6e8b1e268a4b   # -> d18987a5
git -C /tmp/v5363/wt-head-dedup merge --no-edit 4cd079abe4c3df906d2ae968729b6e8b1e268a4b   # -> 48d95c79
# every server tree (base, base+#5362, head checkout @575511fe, head+#5362):
uv sync --frozen --package openhands-agent-server
# client trees (base @5ffdd29, head @575511fe):
cd clients/typescript && npm ci && npm run build
# each server runs in its own cwd with a clean env (no inherited OH_* vars), a local session key, and its own TMUX_TMPDIR:
env -i PATH=... HOME=$RUN TMUX_TMPDIR=$RUN/tmux OH_SESSION_API_KEYS_0=<local key> \
  $TREE/.venv/bin/python -m openhands.agent_server --host 127.0.0.1 --port 1810N
python3 drop_proxy.py 1820N 1810N $RUN/proxy.jsonl
node lost_response.mjs $CLIENT_TREE/clients/typescript http://127.0.0.1:1820N http://127.0.0.1:1810N "<label>"

Results (base client = 5ffdd29, head client = 575511fe):

Scenario Base client × base server 5ffdd29 Base client × base+#5362 d18987a5 Head client × head server 575511fe (no capability) Head client × head+#5362 48d95c79
Lost create response, conversation_id supplied fails: fetch failed (ECONNRESET), even though the server committed it (1 conversation, 1 user message). 1 POST fails: same result, 1 POST. The server has the capability but the client does not retry fails safe: probed once, fetch failed, no replay (1 POST) recovered: returned the same id. POSTs 201, 200, 1 conversation, 1 initial user message
Lost create response, no conversation_id fetch failed, 1 POST fetch failed, 1 POST fetch failed, 1 POST, 0 probes no replay: fetch failed, 1 POST, 0 probes
Real HTTP 422 (unknown agent kind) HttpError 422, 1 POST HttpError 422, 1 POST HttpError 422, 1 POST surfaced unchanged: HttpError 422, 1 POST, 0 probes
Transient /server_info 503, then recovery, on one ConversationClient (two creates, both responses dropped) both creates fail, 0 probes both creates fail, 0 probes both fail safe. 2 probes (503, then 200 without the capability), no replay create A fails safe (probe returns 503, 1 POST, no replay). Create B on the same client probes again (200) and recovers: same id, POSTs 201, 201, 200, 1 user message on each conversation
Transient /server_info 503, then recovery, on one WorkspacesClient (3 × listWorkspaces) fails: 503, 503, 503. Only 1 /server_info request, because the rejected probe stays cached fails: 503, 503, 503. Only 1 /server_info request recovered: 503, OK, OK. 2 /server_info requests recovered: 503, OK, OK. 2 /server_info requests

Negative on base, positive on head:

  • On main 5ffdd29, a lost create response surfaces fetch failed even though the conversation was committed, including against a deduplicating server. A single transient /server_info failure permanently poisons that HttpClient's capability/version cache.
  • On head 575511fe with a deduplicating server, the lost create is re-sent once and returns the original conversation with no duplicate conversation and no duplicate initial message. The same client recovers after a transient /server_info failure.
  • The safety controls hold on head. There is no replay without the capability, without a conversation_id, after a real HTTP 422, or on the operation whose own probe failed.

Caveat: in this sequential lost-response case, a direct re-send of the same id also returned 200 from a server without #5362. The capability gate protects the concurrent create race that #5362 fixes. That race was demonstrated at d22e5df2 (base: four 201s and four initial-message effects; deduplicating server: one effect) and was not repeated here.

Regression suite at head 575511fe (clients/typescript):

  • npx vitest run: 351 passed (24 files).
  • npx tsc --noEmit: clean.
  • npm run format:check: clean.
  • npm run lint: 0 errors. There are 7 warnings, and the same 7 appear on base in files this PR doesn't touch.

Verdict: confirmed at exact head 575511fec4bc2e21d29a45db8c7f5cab2f3dd88b against exact base 5ffdd2933c51423e302f5ee5e5b66df0ad9cc28a.

Harness: drop_proxy.py (response-drop proxy)
"""HTTP reverse proxy that can lose responses after the upstream commits them.

Usage: python drop_proxy.py LISTEN_PORT UPSTREAM_PORT LOG_PATH

Control (never forwarded):
  POST /__proxy/arm   {"drop_create": N, "fail_server_info": M}
      drop_create: forward the next N `POST /api/conversations` upstream, wait for
        the upstream response (so the create is committed), then reset the client
        TCP connection without sending any response bytes.
      fail_server_info: answer the next M `GET /server_info` with HTTP 503.
  GET  /__proxy/stats
  POST /__proxy/reset
"""

import http.client
import json
import socket
import struct
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer


LISTEN_PORT, UPSTREAM_PORT, LOG_PATH = int(sys.argv[1]), int(sys.argv[2]), sys.argv[3]
HOP = {"connection", "keep-alive", "transfer-encoding", "te", "upgrade", "trailer"}
lock = threading.Lock()
state = {"drop_create": 0, "fail_server_info": 0}
stats: dict = {}


def reset_stats():
    stats.clear()
    stats.update(
        {"create_posts": 0, "create_dropped": 0, "create_upstream_status": [],
         "server_info_gets": 0, "server_info_503": 0, "log": []}
    )


reset_stats()


def log(entry: dict):
    entry["t"] = round(time.time(), 3)
    stats["log"].append(entry)
    with open(LOG_PATH, "a") as f:
        f.write(json.dumps(entry) + "\n")


class Handler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def log_message(self, *args):
        pass

    def _json(self, code: int, body: dict):
        data = json.dumps(body).encode()
        self.send_response(code)
        self.send_header("content-type", "application/json")
        self.send_header("content-length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    def _body(self) -> bytes:
        n = int(self.headers.get("content-length") or 0)
        return self.rfile.read(n) if n else b""

    def _reset_connection(self):
        sock = self.connection
        sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0))
        sock.close()
        self.close_connection = True

    def _handle(self):
        body = self._body()
        path = self.path.split("?")[0]
        if path.startswith("/__proxy/"):
            with lock:
                if path == "/__proxy/arm":
                    state.update(json.loads(body or b"{}"))
                    log({"control": "arm", **state})
                    return self._json(200, dict(state))
                if path == "/__proxy/reset":
                    state.update(drop_create=0, fail_server_info=0)
                    reset_stats()
                    return self._json(200, {"ok": True})
                return self._json(200, {**stats, "armed": dict(state)})

        is_create = self.command == "POST" and path == "/api/conversations"
        is_info = self.command == "GET" and path == "/server_info"
        with lock:
            drop = fail_info = False
            if is_create:
                stats["create_posts"] += 1
                if state["drop_create"] > 0:
                    state["drop_create"] -= 1
                    drop = True
            if is_info:
                stats["server_info_gets"] += 1
                if state["fail_server_info"] > 0:
                    state["fail_server_info"] -= 1
                    fail_info = True

        if fail_info:
            stats["server_info_503"] += 1
            log({"req": "GET /server_info", "action": "injected 503"})
            return self._json(503, {"detail": "injected transient failure"})

        headers = {k: v for k, v in self.headers.items() if k.lower() not in HOP}
        upstream = http.client.HTTPConnection("127.0.0.1", UPSTREAM_PORT, timeout=120)
        upstream.request(self.command, self.path, body=body or None, headers=headers)
        resp = upstream.getresponse()
        data = resp.read()
        upstream.close()

        if is_create:
            stats["create_upstream_status"].append(resp.status)
        entry = {"req": f"{self.command} {path}", "upstream_status": resp.status}
        if is_create:
            try:
                entry["upstream_id"] = json.loads(data).get("id")
            except Exception:
                pass
        if drop:
            stats["create_dropped"] += 1
            entry["action"] = "committed upstream, response dropped (TCP RST)"
            log(entry)
            return self._reset_connection()
        if is_create or is_info:
            entry["action"] = "forwarded"
            log(entry)

        self.send_response(resp.status)
        for k, v in resp.getheaders():
            if k.lower() not in HOP and k.lower() != "content-length":
                self.send_header(k, v)
        self.send_header("content-length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = _handle


ThreadingHTTPServer(("127.0.0.1", LISTEN_PORT), Handler).serve_forever()
Harness: lost_response.mjs (drives the compiled client)
// Live lost-response validation against the *compiled* TypeScript client.
// usage: node lost_response.mjs <clients/typescript dir> <proxy url> <upstream url> <label>
import { randomUUID } from 'node:crypto';
import { pathToFileURL } from 'node:url';

const [clientDir, proxyUrl, upstreamUrl, label] = process.argv.slice(2);
const dist = (p) => pathToFileURL(`${clientDir}/dist/${p}`).href;
const { ConversationClient, WorkspacesClient } = await import(dist('clients.js'));
const { HttpError } = await import(dist('index.js'));

const apiKey = 'v5363-local-key';
const H = { 'X-Session-API-Key': apiKey, 'content-type': 'application/json' };

async function proxy(path, body) {
  const r = await fetch(`${proxyUrl}/__proxy/${path}`, {
    method: body === undefined ? 'GET' : 'POST',
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  return r.json();
}

async function upstream(path) {
  const r = await fetch(`${upstreamUrl}${path}`, { headers: H });
  return { status: r.status, body: r.status === 200 ? await r.json() : null };
}

async function effects(id) {
  const info = await upstream(`/api/conversations/${id}`);
  if (info.status !== 200) return { exists: false, userMessages: 0 };
  const ev = await upstream(`/api/conversations/${id}/events/search?limit=100`);
  const userMessages = ev.body.items.filter(
    (e) => e.kind === 'MessageEvent' && e.source === 'user'
  ).length;
  return { exists: true, userMessages };
}

function payload(id, text) {
  return {
    ...(id ? { conversation_id: id } : {}),
    agent: { kind: 'Agent', llm: { model: 'dummy/model', api_key: 'dummy-key' }, tools: [] },
    workspace: { kind: 'LocalWorkspace', working_dir: '/tmp/v5363/ws' },
    initial_message: { role: 'user', content: [{ type: 'text', text }], run: false },
  };
}

function describe(err) {
  return {
    ok: false,
    httpError: err instanceof HttpError,
    status: err instanceof HttpError ? err.status : undefined,
    message: err?.message,
    cause: err?.cause?.message,
    rootCause: err?.cause?.cause?.code ?? err?.cause?.cause?.message,
  };
}

async function attempt(fn) {
  try {
    const v = await fn();
    return { ok: true, id: v?.id, workspaces: v?.workspaces?.length };
  } catch (err) {
    return describe(err);
  }
}

async function countAll() {
  return (await upstream('/api/conversations/count')).body;
}

async function scenario(name, arm, run) {
  await proxy('reset', {});
  if (arm) await proxy('arm', arm);
  const before = await countAll();
  const detail = await run();
  const stats = await proxy('stats');
  const after = await countAll();
  const out = {
    scenario: name,
    ...detail,
    conversationsCreated: after - before,
    proxy: {
      create_posts: stats.create_posts,
      create_dropped: stats.create_dropped,
      create_upstream_status: stats.create_upstream_status,
      server_info_gets: stats.server_info_gets,
      server_info_503: stats.server_info_503,
    },
  };
  console.log(JSON.stringify(out));
  return out;
}

const serverInfo = (await upstream('/server_info')).body;
console.log(
  JSON.stringify({
    label,
    clientDir,
    upstreamCapability: serverInfo.capabilities.includes('idempotent_conversation_create_v1'),
  })
);

// 1. Lost create response, caller-supplied conversation_id.
await scenario('lost_create_with_id', { drop_create: 1 }, async () => {
  const client = new ConversationClient({ host: proxyUrl, apiKey });
  const id = randomUUID();
  const result = await attempt(() => client.createConversation(payload(id, `lost ${id}`)));
  return { result, sameId: result.id === id, effects: await effects(id) };
});

// 2. Lost create response without conversation_id: must never replay.
await scenario('lost_create_without_id', { drop_create: 1 }, async () => {
  const client = new ConversationClient({ host: proxyUrl, apiKey });
  return { result: await attempt(() => client.createConversation(payload(null, 'no id'))) };
});

// 3. Real HTTP 422 must surface unchanged and not be re-sent.
await scenario('http_422_not_retried', null, async () => {
  const client = new ConversationClient({ host: proxyUrl, apiKey });
  const id = randomUUID();
  const bad = { ...payload(id, 'bad'), agent: { kind: 'NoSuchAgent' } };
  return { result: await attempt(() => client.createConversation(bad)) };
});

// 4. Transient /server_info failure then recovery, on ONE ConversationClient.
await scenario(
  'server_info_transient_same_conversation_client',
  { drop_create: 2, fail_server_info: 1 },
  async () => {
    const client = new ConversationClient({ host: proxyUrl, apiKey });
    const a = randomUUID();
    const b = randomUUID();
    const first = await attempt(() => client.createConversation(payload(a, `A ${a}`)));
    const second = await attempt(() => client.createConversation(payload(b, `B ${b}`)));
    return {
      first,
      firstEffects: await effects(a),
      second,
      secondSameId: second.id === b,
      secondEffects: await effects(b),
    };
  }
);

// 5. Transient /server_info failure then recovery, on ONE WorkspacesClient
//    (the version-gated path that shares the same per-HttpClient cache).
await scenario('server_info_transient_same_workspaces_client', { fail_server_info: 1 }, async () => {
  const client = new WorkspacesClient({ host: proxyUrl, apiKey });
  const first = await attempt(() => client.listWorkspaces());
  const second = await attempt(() => client.listWorkspaces());
  const third = await attempt(() => client.listWorkspaces());
  return { first, second, third };
});
Raw harness output (JSON lines, one file per client × server pair)
# out-base-x-base.jsonl
{"label":"BASE client 5ffdd29 x BASE server 5ffdd29","clientDir":"/tmp/v5363/wt-base/clients/typescript","upstreamCapability":false}
{"scenario":"lost_create_with_id","result":{"ok":false,"httpError":false,"message":"Request failed: fetch failed","cause":"fetch failed","rootCause":"ECONNRESET"},"sameId":false,"effects":{"exists":true,"userMessages":1},"conversationsCreated":1,"proxy":{"create_posts":1,"create_dropped":1,"create_upstream_status":[201],"server_info_gets":0,"server_info_503":0}}
{"scenario":"lost_create_without_id","result":{"ok":false,"httpError":false,"message":"Request failed: fetch failed","cause":"fetch failed","rootCause":"ECONNRESET"},"conversationsCreated":1,"proxy":{"create_posts":1,"create_dropped":1,"create_upstream_status":[201],"server_info_gets":0,"server_info_503":0}}
{"scenario":"http_422_not_retried","result":{"ok":false,"httpError":true,"status":422,"message":"HTTP request failed (422 Unprocessable Content): {\"detail\":[{\"type\":\"value_error\",\"loc\":[\"body\",\"agent\"],\"msg\":\"Value error, Unknown kind 'NoSuchAgent' for openhands.sdk.agent.base.AgentBase; Expected one of: ['ACPAgent', 'Agent']\",\"input\":{},\"ctx\":{\"error\":\"Unknown kind 'NoSuchAgent' for openhands.sdk.agent.base.AgentBase; Expected one of: ['ACPAgent', 'Agent']\"}}]}"},"conversationsCreated":0,"proxy":{"create_posts":1,"create_dropped":0,"create_upstream_status":[422],"server_info_gets":0,"server_info_503":0}}
{"scenario":"server_info_transient_same_conversation_client","first":{"ok":false,"httpError":false,"message":"Request failed: fetch failed","cause":"fetch failed","rootCause":"ECONNRESET"},"firstEffects":{"exists":true,"userMessages":1},"second":{"ok":false,"httpError":false,"message":"Request failed: fetch failed","cause":"fetch failed","rootCause":"ECONNRESET"},"secondSameId":false,"secondEffects":{"exists":true,"userMessages":1},"conversationsCreated":2,"proxy":{"create_posts":2,"create_dropped":2,"create_upstream_status":[201,201],"server_info_gets":0,"server_info_503":0}}
{"scenario":"server_info_transient_same_workspaces_client","first":{"ok":false,"httpError":true,"status":503,"message":"HTTP request failed (503 Service Unavailable): {\"detail\":\"injected transient failure\"}"},"second":{"ok":false,"httpError":true,"status":503,"message":"HTTP request failed (503 Service Unavailable): {\"detail\":\"injected transient failure\"}"},"third":{"ok":false,"httpError":true,"status":503,"message":"HTTP request failed (503 Service Unavailable): {\"detail\":\"injected transient failure\"}"},"conversationsCreated":0,"proxy":{"create_posts":0,"create_dropped":0,"create_upstream_status":[],"server_info_gets":1,"server_info_503":1}}

# out-base-x-basededup.jsonl
{"label":"BASE client 5ffdd29 x base+#5362 server d18987a","clientDir":"/tmp/v5363/wt-base/clients/typescript","upstreamCapability":true}
{"scenario":"lost_create_with_id","result":{"ok":false,"httpError":false,"message":"Request failed: fetch failed","cause":"fetch failed","rootCause":"ECONNRESET"},"sameId":false,"effects":{"exists":true,"userMessages":1},"conversationsCreated":1,"proxy":{"create_posts":1,"create_dropped":1,"create_upstream_status":[201],"server_info_gets":0,"server_info_503":0}}
{"scenario":"lost_create_without_id","result":{"ok":false,"httpError":false,"message":"Request failed: fetch failed","cause":"fetch failed","rootCause":"ECONNRESET"},"conversationsCreated":1,"proxy":{"create_posts":1,"create_dropped":1,"create_upstream_status":[201],"server_info_gets":0,"server_info_503":0}}
{"scenario":"http_422_not_retried","result":{"ok":false,"httpError":true,"status":422,"message":"HTTP request failed (422 Unprocessable Content): {\"detail\":[{\"type\":\"value_error\",\"loc\":[\"body\",\"agent\"],\"msg\":\"Value error, Unknown kind 'NoSuchAgent' for openhands.sdk.agent.base.AgentBase; Expected one of: ['ACPAgent', 'Agent']\",\"input\":{},\"ctx\":{\"error\":\"Unknown kind 'NoSuchAgent' for openhands.sdk.agent.base.AgentBase; Expected one of: ['ACPAgent', 'Agent']\"}}]}"},"conversationsCreated":0,"proxy":{"create_posts":1,"create_dropped":0,"create_upstream_status":[422],"server_info_gets":0,"server_info_503":0}}
{"scenario":"server_info_transient_same_conversation_client","first":{"ok":false,"httpError":false,"message":"Request failed: fetch failed","cause":"fetch failed","rootCause":"ECONNRESET"},"firstEffects":{"exists":true,"userMessages":1},"second":{"ok":false,"httpError":false,"message":"Request failed: fetch failed","cause":"fetch failed","rootCause":"ECONNRESET"},"secondSameId":false,"secondEffects":{"exists":true,"userMessages":1},"conversationsCreated":2,"proxy":{"create_posts":2,"create_dropped":2,"create_upstream_status":[201,201],"server_info_gets":0,"server_info_503":0}}
{"scenario":"server_info_transient_same_workspaces_client","first":{"ok":false,"httpError":true,"status":503,"message":"HTTP request failed (503 Service Unavailable): {\"detail\":\"injected transient failure\"}"},"second":{"ok":false,"httpError":true,"status":503,"message":"HTTP request failed (503 Service Unavailable): {\"detail\":\"injected transient failure\"}"},"third":{"ok":false,"httpError":true,"status":503,"message":"HTTP request failed (503 Service Unavailable): {\"detail\":\"injected transient failure\"}"},"conversationsCreated":0,"proxy":{"create_posts":0,"create_dropped":0,"create_upstream_status":[],"server_info_gets":1,"server_info_503":1}}

# out-head-x-head.jsonl
{"label":"HEAD client 575511f x HEAD server 575511f","clientDir":"/workspace/project/software-agent-sdk/clients/typescript","upstreamCapability":false}
{"scenario":"lost_create_with_id","result":{"ok":false,"httpError":false,"message":"Request failed: fetch failed","cause":"fetch failed","rootCause":"ECONNRESET"},"sameId":false,"effects":{"exists":true,"userMessages":1},"conversationsCreated":1,"proxy":{"create_posts":1,"create_dropped":1,"create_upstream_status":[201],"server_info_gets":1,"server_info_503":0}}
{"scenario":"lost_create_without_id","result":{"ok":false,"httpError":false,"message":"Request failed: fetch failed","cause":"fetch failed","rootCause":"ECONNRESET"},"conversationsCreated":1,"proxy":{"create_posts":1,"create_dropped":1,"create_upstream_status":[201],"server_info_gets":0,"server_info_503":0}}
{"scenario":"http_422_not_retried","result":{"ok":false,"httpError":true,"status":422,"message":"HTTP request failed (422 Unprocessable Content): {\"detail\":[{\"type\":\"value_error\",\"loc\":[\"body\",\"agent\"],\"msg\":\"Value error, Unknown kind 'NoSuchAgent' for openhands.sdk.agent.base.AgentBase; Expected one of: ['ACPAgent', 'Agent']\",\"input\":{},\"ctx\":{\"error\":\"Unknown kind 'NoSuchAgent' for openhands.sdk.agent.base.AgentBase; Expected one of: ['ACPAgent', 'Agent']\"}}]}"},"conversationsCreated":0,"proxy":{"create_posts":1,"create_dropped":0,"create_upstream_status":[422],"server_info_gets":0,"server_info_503":0}}
{"scenario":"server_info_transient_same_conversation_client","first":{"ok":false,"httpError":false,"message":"Request failed: fetch failed","cause":"fetch failed","rootCause":"ECONNRESET"},"firstEffects":{"exists":true,"userMessages":1},"second":{"ok":false,"httpError":false,"message":"Request failed: fetch failed","cause":"fetch failed","rootCause":"ECONNRESET"},"secondSameId":false,"secondEffects":{"exists":true,"userMessages":1},"conversationsCreated":2,"proxy":{"create_posts":2,"create_dropped":2,"create_upstream_status":[201,201],"server_info_gets":2,"server_info_503":1}}
{"scenario":"server_info_transient_same_workspaces_client","first":{"ok":false,"httpError":true,"status":503,"message":"HTTP request failed (503 Service Unavailable): {\"detail\":\"injected transient failure\"}"},"second":{"ok":true,"workspaces":0},"third":{"ok":true,"workspaces":0},"conversationsCreated":0,"proxy":{"create_posts":0,"create_dropped":0,"create_upstream_status":[],"server_info_gets":2,"server_info_503":1}}

# out-head-x-headdedup.jsonl
{"label":"HEAD client 575511f x head+#5362 server 48d95c7","clientDir":"/workspace/project/software-agent-sdk/clients/typescript","upstreamCapability":true}
{"scenario":"lost_create_with_id","result":{"ok":true,"id":"88449f39-5221-4ffd-9bc3-7e277c3e65a4"},"sameId":true,"effects":{"exists":true,"userMessages":1},"conversationsCreated":1,"proxy":{"create_posts":2,"create_dropped":1,"create_upstream_status":[201,200],"server_info_gets":1,"server_info_503":0}}
{"scenario":"lost_create_without_id","result":{"ok":false,"httpError":false,"message":"Request failed: fetch failed","cause":"fetch failed","rootCause":"ECONNRESET"},"conversationsCreated":1,"proxy":{"create_posts":1,"create_dropped":1,"create_upstream_status":[201],"server_info_gets":0,"server_info_503":0}}
{"scenario":"http_422_not_retried","result":{"ok":false,"httpError":true,"status":422,"message":"HTTP request failed (422 Unprocessable Content): {\"detail\":[{\"type\":\"value_error\",\"loc\":[\"body\",\"agent\"],\"msg\":\"Value error, Unknown kind 'NoSuchAgent' for openhands.sdk.agent.base.AgentBase; Expected one of: ['ACPAgent', 'Agent']\",\"input\":{},\"ctx\":{\"error\":\"Unknown kind 'NoSuchAgent' for openhands.sdk.agent.base.AgentBase; Expected one of: ['ACPAgent', 'Agent']\"}}]}"},"conversationsCreated":0,"proxy":{"create_posts":1,"create_dropped":0,"create_upstream_status":[422],"server_info_gets":0,"server_info_503":0}}
{"scenario":"server_info_transient_same_conversation_client","first":{"ok":false,"httpError":false,"message":"Request failed: fetch failed","cause":"fetch failed","rootCause":"ECONNRESET"},"firstEffects":{"exists":true,"userMessages":1},"second":{"ok":true,"id":"69b8e899-7a64-43a4-af1a-2715af71bc2a"},"secondSameId":true,"secondEffects":{"exists":true,"userMessages":1},"conversationsCreated":2,"proxy":{"create_posts":3,"create_dropped":2,"create_upstream_status":[201,201,200],"server_info_gets":2,"server_info_503":1}}
{"scenario":"server_info_transient_same_workspaces_client","first":{"ok":false,"httpError":true,"status":503,"message":"HTTP request failed (503 Service Unavailable): {\"detail\":\"injected transient failure\"}"},"second":{"ok":true,"workspaces":0},"third":{"ok":true,"workspaces":0},"conversationsCreated":0,"proxy":{"create_posts":0,"create_dropped":0,"create_upstream_status":[],"server_info_gets":2,"server_info_503":1}}

This validation section was updated by an AI agent (OpenHands) on behalf of Graham Neubig.


🐳 Agent Server images for this PR — GHCR package, pull/run commands, and all pushed tags (click to expand)

• GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python-slim amd64, arm64 python-node-runtime Link
python-minimal amd64, arm64 python-node-runtime Link
python amd64, arm64 python-node-runtime Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:575511f-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-575511f-python \
  ghcr.io/openhands/agent-server:575511f-python

All tags pushed for this build

ghcr.io/openhands/agent-server:575511f-golang-amd64
ghcr.io/openhands/agent-server:575511fec4bc2e21d29a45db8c7f5cab2f3dd88b-golang-amd64
ghcr.io/openhands/agent-server:fix-typescript-create-retry-capability-golang-amd64
ghcr.io/openhands/agent-server:575511f-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:575511f-golang-arm64
ghcr.io/openhands/agent-server:575511fec4bc2e21d29a45db8c7f5cab2f3dd88b-golang-arm64
ghcr.io/openhands/agent-server:fix-typescript-create-retry-capability-golang-arm64
ghcr.io/openhands/agent-server:575511f-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:575511f-java-amd64
ghcr.io/openhands/agent-server:575511fec4bc2e21d29a45db8c7f5cab2f3dd88b-java-amd64
ghcr.io/openhands/agent-server:fix-typescript-create-retry-capability-java-amd64
ghcr.io/openhands/agent-server:575511f-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:575511f-java-arm64
ghcr.io/openhands/agent-server:575511fec4bc2e21d29a45db8c7f5cab2f3dd88b-java-arm64
ghcr.io/openhands/agent-server:fix-typescript-create-retry-capability-java-arm64
ghcr.io/openhands/agent-server:575511f-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:575511f-python-amd64
ghcr.io/openhands/agent-server:575511fec4bc2e21d29a45db8c7f5cab2f3dd88b-python-amd64
ghcr.io/openhands/agent-server:fix-typescript-create-retry-capability-python-amd64
ghcr.io/openhands/agent-server:575511f-python-node-runtime-amd64
ghcr.io/openhands/agent-server:575511f-python-arm64
ghcr.io/openhands/agent-server:575511fec4bc2e21d29a45db8c7f5cab2f3dd88b-python-arm64
ghcr.io/openhands/agent-server:fix-typescript-create-retry-capability-python-arm64
ghcr.io/openhands/agent-server:575511f-python-node-runtime-arm64
ghcr.io/openhands/agent-server:575511f-python-minimal-amd64
ghcr.io/openhands/agent-server:575511fec4bc2e21d29a45db8c7f5cab2f3dd88b-python-minimal-amd64
ghcr.io/openhands/agent-server:fix-typescript-create-retry-capability-python-minimal-amd64
ghcr.io/openhands/agent-server:575511f-python-node-runtime-minimal-amd64
ghcr.io/openhands/agent-server:575511f-python-minimal-arm64
ghcr.io/openhands/agent-server:575511fec4bc2e21d29a45db8c7f5cab2f3dd88b-python-minimal-arm64
ghcr.io/openhands/agent-server:fix-typescript-create-retry-capability-python-minimal-arm64
ghcr.io/openhands/agent-server:575511f-python-node-runtime-minimal-arm64
ghcr.io/openhands/agent-server:575511f-python-slim-amd64
ghcr.io/openhands/agent-server:575511fec4bc2e21d29a45db8c7f5cab2f3dd88b-python-slim-amd64
ghcr.io/openhands/agent-server:fix-typescript-create-retry-capability-python-slim-amd64
ghcr.io/openhands/agent-server:575511f-python-node-runtime-slim-amd64
ghcr.io/openhands/agent-server:575511f-python-slim-arm64
ghcr.io/openhands/agent-server:575511fec4bc2e21d29a45db8c7f5cab2f3dd88b-python-slim-arm64
ghcr.io/openhands/agent-server:fix-typescript-create-retry-capability-python-slim-arm64
ghcr.io/openhands/agent-server:575511f-python-node-runtime-slim-arm64
ghcr.io/openhands/agent-server:575511f-golang
ghcr.io/openhands/agent-server:575511fec4bc2e21d29a45db8c7f5cab2f3dd88b-golang
ghcr.io/openhands/agent-server:fix-typescript-create-retry-capability-golang
ghcr.io/openhands/agent-server:575511f-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:575511f-java
ghcr.io/openhands/agent-server:575511fec4bc2e21d29a45db8c7f5cab2f3dd88b-java
ghcr.io/openhands/agent-server:fix-typescript-create-retry-capability-java
ghcr.io/openhands/agent-server:575511f-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:575511f-python-minimal
ghcr.io/openhands/agent-server:575511fec4bc2e21d29a45db8c7f5cab2f3dd88b-python-minimal
ghcr.io/openhands/agent-server:fix-typescript-create-retry-capability-python-minimal
ghcr.io/openhands/agent-server:575511f-python-node-runtime-minimal
ghcr.io/openhands/agent-server:575511f-python-slim
ghcr.io/openhands/agent-server:575511fec4bc2e21d29a45db8c7f5cab2f3dd88b-python-slim
ghcr.io/openhands/agent-server:fix-typescript-create-retry-capability-python-slim
ghcr.io/openhands/agent-server:575511f-python-node-runtime-slim
ghcr.io/openhands/agent-server:575511f-python
ghcr.io/openhands/agent-server:575511fec4bc2e21d29a45db8c7f5cab2f3dd88b-python
ghcr.io/openhands/agent-server:fix-typescript-create-retry-capability-python
ghcr.io/openhands/agent-server:575511f-python-node-runtime

About Multi-Architecture Support

  • Each variant tag (e.g., 575511f-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., 575511f-python-amd64) are also available if needed

@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result: ✅ PASSED

Action log

@github-actions

Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-agent-server/openhands/agent_server
   conversation_service.py126513290%203–204, 213, 240–241, 245–246, 251, 397–398, 401–402, 418–419, 433, 600–601, 662, 765, 772–773, 874, 960, 1011–1012, 1019, 1051–1052, 1068, 1099, 1103, 1115, 1135, 1147–1150, 1156–1157, 1166, 1168, 1234, 1244, 1269, 1275–1276, 1280–1281, 1289, 1316, 1322, 1416, 1422, 1427, 1433, 1441–1442, 1451–1454, 1463, 1526, 1530–1534, 1557, 1606, 1619, 1627, 1679–1680, 1684, 1774–1775, 1846, 1910–1912, 1914–1915, 1918–1919, 1956, 2030–2031, 2063, 2066, 2073–2075, 2078–2079, 2083–2085, 2088–2089, 2093–2095, 2098–2099, 2128, 2137, 2180, 2197–2199, 2259, 2262, 2289, 2299, 2304–2307, 2321, 2332, 2344–2345, 2377, 2472, 2529, 2587, 2602–2603, 2986, 3039, 3042
   server_details_router.py61297%27–28
TOTAL450851867559% 

@neubig
neubig force-pushed the fix/typescript-create-retry-capability branch from cf8c5cc to 13119a3 Compare September 29, 2026 03:21
createConversation surfaces a transport error when it loses the response. The
caller cannot tell whether the create was applied, and replaying the POST risks
a second conversation whose initial message runs twice.

Re-send once, but only when the server advertises
idempotent_conversation_create_v1 (added server-side in the sibling fix), and
only when the caller supplied a conversation_id to deduplicate on. A server
that cannot be probed is treated as unsupported, so an older backend keeps
surfacing the transport error rather than risking a duplicate.

Tests use a real HTTP server and assert the negative directly: against a server
without the capability the create is not replayed, and the re-send is
deduplicated rather than applied twice.

Co-authored-by: openhands <openhands@all-hands.dev>
@neubig
neubig force-pushed the fix/typescript-create-retry-capability branch from c0611e2 to d22e5df Compare September 29, 2026 04:24
@neubig
neubig marked this pull request as ready for review September 29, 2026 09:45

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was posted by an AI agent (OpenHands).

Assessment

I checked out the exact head d22e5df2 and reviewed clients/typescript/src/client/conversation-client.ts and the new src/__tests__/conversation-create-retry.test.ts against the repo review guide and clients/typescript/AGENTS.md.

The implementation is correct and appropriately conservative. The retry is gated on three independent conditions, all of which are necessary:

  • error instanceof HttpError is rejected first, so a real HTTP answer (including a 400 validation failure) is never concealed — the transport error from HttpClient.request is wrapped in a plain Error, so the type check correctly distinguishes the two cases.
  • typeof payload.conversation_id !== 'string' ensures there is a stable dedup key; a create with no caller-supplied id is not replayed (that would double-create).
  • supportsCreateRetry() probes /server_info via the existing per-HttpClient cache and returns false on any probe failure, so an older or unreachable backend keeps surfacing the original transport error rather than risking a duplicate.

I ran the new suite and the full unit suite in the workspace:

  • npx vitest run --config vitest.config.ts src/__tests__/conversation-create-retry.test.ts -> 4 passed. The tests drive a real node:http server, so they exercise the actual lost-response path rather than a stubbed fetch. The negative cases (capability absent -> creates === 1; no conversation_id -> not replayed; 400 surfaced) assert the safety properties directly.
  • Full npx vitest run --config vitest.config.ts -> 334 passed (21 files), matching the PR's stated result.

Current-head CI is green for the substantive jobs: TypeScript client CI (agent-server-api, build, test, endpoint-audit, public-type-budget), Run tests, and TypeScript client integration tests all pass. Note this change is inert on main until #5362 (still open) advertises idempotent_conversation_create_v1, which is the intended safe behavior.

Blocker

One current-head check fails: Validate PR description (run 36551179202, job 109349459173), and the PR is BLOCKED for merge. The failure is:

##[error]Add a short human-written note between `HUMAN:` and `AGENT:`.
PR description validation failed with 1 error(s).

The HUMAN: section of the PR body still contains only the unfilled template comment. Repository policy (.github/workflows/pr-description-check.yml + .github/scripts/check_pr_description.py, MIN_HUMAN_NOTE_CHARS = 20) requires a human-written note there, and the AGENT section must not edit it. This is the only thing standing between this revision and approval; it needs the human author (@neubig), not an AI agent, to add the note. No code change is required.

Linked issue

#5035 is open, labeled ready-for-dev (carries enhancement, sdk, javascript), and its updated acceptance criteria now describe exactly this remedy (client re-send gated on the capability + a caller-supplied id; HTTP failures preserved). The head satisfies the client-side criteria; the server-side criterion lives in #5362.

No line-level findings to report on the changed files.

🔄 CHANGES REQUESTED

@neubig

neubig commented Sep 29, 2026

Copy link
Copy Markdown
Member Author

The remaining Validate PR description failure requires a short human-authored note between the protected HUMAN: and AGENT: markers. I did not fabricate that attestation. Please add a brief note in your own words; CI should then rerun automatically.\n\n_This comment was created by an AI agent (OpenHands) on behalf of Gregory Neubig._

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was posted by an AI agent (OpenHands).

Assessment

I re-fetched the current state and checked out the exact head d22e5df2 (unchanged). This is a fresh pass, warranted because @neubig commented after my earlier review. Scope is correct: this is the TypeScript client for the Agent Server API, owned by this repo per clients/typescript/AGENTS.md.

The implementation is correct and appropriately conservative. createConversation re-sends only when all three conditions hold:

  • error instanceof HttpError is rejected first, so a real HTTP answer (including a 400 validation failure) is never concealed. HttpClient.request wraps transport faults in a plain Error and throws HttpError only for a received non-OK response, so the type check correctly separates the two cases (verified in clients/typescript/src/client/http-client.ts).
  • typeof payload.conversation_id !== 'string' ensures a stable dedup key; a create with no caller-supplied id is not replayed.
  • supportsCreateRetry() probes /server_info via the existing per-HttpClient cache (getCachedAgentServerInfo) and returns false on any probe failure, so an older or unreachable backend keeps surfacing the original transport error instead of risking a duplicate. The capability string idempotent_conversation_create_v1 matches exactly what the server-side PR advertises in ServerInfo.capabilities.

I ran the focused suite and the full unit suite against this head, plus the typecheck:

  • npx vitest run --config vitest.config.ts src/__tests__/conversation-create-retry.test.ts -> 4 passed. The tests drive a real node:http server, so they exercise the actual lost-response path (a stubbed fetch would not). The negative cases assert the safety properties directly: capability absent -> state.creates === 1; no conversation_id -> not replayed; 400 surfaced as HttpError.
  • Full npx vitest run --config vitest.config.ts -> 334 passed (21 files), matching the PR's stated result.
  • npx tsc --noEmit -> clean.

All current-head substantive checks pass (test (22.12), test (24.x), build, agent-server-api, endpoint-audit, public-type-budget, integration-test). The change is inert until #5362 (still open) advertises the capability, which is the intended safe default.

Blocker

One current-head check fails: Validate PR description (run 36551179202, job 109349459173), and the PR's mergeable_state is blocked:

##[error]Add a short human-written note between `HUMAN:` and `AGENT:`.
PR description validation failed with 1 error(s).

The HUMAN: region of the PR body still contains only the unfilled template comment. Repository policy (.github/workflows/pr-description-check.yml + .github/scripts/check_pr_description.py, MIN_HUMAN_NOTE_CHARS = 20) requires a human-written note there, and AI agents must not edit that section. @neubig already acknowledged this in a comment; it needs the human author to add the note. No code change is required — this is the only thing between this revision and approval.

Linked issue

#5035 is open with labels enhancement, sdk, javascript, ready-for-dev (still present). Its updated acceptance criteria describe exactly this remedy (client re-send gated on the server capability plus a caller-supplied id; explicit HTTP failures preserved). This head satisfies the client-side criteria; the server-side criterion lives in #5362.

No line-level findings on the changed files.

🔄 CHANGES REQUESTED

@neubig
neubig requested review from all-hands-bot and removed request for all-hands-bot September 29, 2026 17:09
@neubig

neubig commented Sep 30, 2026 •

Copy link
Copy Markdown
Member Author

Resolved: transient server-info failures now recover

Fixed in 335f5d20.

A rejected /server_info request is now removed from the WeakMap with an identity guard. Successful requests and concurrent in-flight probes remain cached, but a later operation can re-probe after a transient failure. The original error still propagates, so the create-retry path remains fail-safe and will not replay a create unless a successful capability probe confirms deduplication support.

Added an observable regression test on one WorkspacesClient: the first capability probe returns 503, then the same client re-probes successfully and lists workspaces. The test failed before the fix and passes after it.

Validation:

  • full TypeScript suite: 335 passed
  • affected suites: 91 passed
  • lint: 0 errors
  • build and format check: passed

This comment was updated by an AI agent (OpenHands) on behalf of Graham Neubig.

Co-authored-by: openhands <openhands@all-hands.dev>
@neubig
neubig requested review from all-hands-bot and removed request for all-hands-bot September 30, 2026 02:11

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was posted by an AI agent (OpenHands).

Assessment

I checked out the exact head 575511fe (a merge of main into the feature branch) and reviewed the four changed files against the repo guidance and clients/typescript/AGENTS.md. Scope is correct: this is the TypeScript client for the Agent Server API, owned by this repository, and it follows the SDK/Agent Server → OpenAPI → clients/typescript flow.

The core retry logic is correct and appropriately conservative. createConversation re-sends only when all three conditions hold:

  • error instanceof HttpError is rejected first, so a real HTTP answer (including a 4xx validation failure) is never concealed. HttpClient.request throws HttpError only for a received non-OK response and wraps transport faults in a plain Error, so the type check correctly separates the two cases.
  • typeof payload.conversation_id !== 'string' ensures a stable dedup key; a create with no caller-supplied id is not replayed.
  • supportsCreateRetry() probes /server_info via the cached getCachedAgentServerInfo and returns false on any probe failure, so an older or unreachable backend keeps surfacing the original transport error rather than risking a duplicate. The capability string idempotent_conversation_create_v1 matches exactly what the server-side PR advertises in ServerInfo.capabilities.

The follow-up cache change (agent-server-compatibility.ts) is also correct. Evicting a rejected /server_info promise from the WeakMap under an identity guard (serverInfoCache.get(client) === request) lets a later operation re-probe after a transient failure, while successful and concurrent in-flight probes stay cached. The void request.catch(...) cannot produce an unhandled rejection because the caller of getCachedAgentServerInfo still awaits and handles request; this is a strict improvement over the previous behavior, where a failed probe poisoned the cache until clearAgentServerInfoCache was called manually. The createConversation path remains fail-safe: supportsCreateRetry() still returns false on a failed probe, so no create is replayed without a confirmed capability.

Verification on this head

  • npx vitest run --config vitest.config.ts src/__tests__/conversation-create-retry.test.ts src/__tests__/api-clients.test.ts -> 91 passed (2 files), including the new transient-/server_info-failure regression, which asserts a second listWorkspaces() re-probes and succeeds.
  • Full npx vitest run --config vitest.config.ts -> 351 passed.
  • npx tsc --noEmit -> clean.

All current-head checks are green, including Validate PR description (now that a human note is present between the HUMAN: and AGENT: markers), test (22.12), test (24.x), build, agent-server-api, endpoint-audit, public-type-budget, integration-test, pre-commit, and the binary builds. No failing or pending checks remain.

Linked issue

#5035 is open, labeled enhancement, sdk, javascript, ready-for-dev. Its updated acceptance criteria describe exactly this remedy, and this head satisfies the client-side criteria; the server-side criterion lives in the still-open #5362. The change is inert on a server without the capability, which is the intended safe default.

Note: mergeable_state is blocked, which is expected while the PR awaits its required human approval and the #5362 dependency; it is not a code-level blocker on this head.

No line-level findings.

✅ APPROVED

@juanmichelini juanmichelini left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@neubig
neubig merged commit 28c3a2d into main Oct 3, 2026
60 checks passed
@neubig
neubig deleted the fix/typescript-create-retry-capability branch October 3, 2026 18:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Recover a conversation after the TypeScript client loses its create response

4 participants