Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
19f2af0
feat(voice): add fast commands and existing-thread follow-ups
github-actions[bot] Sep 14, 2026
a1784b7
feat(voice): configure voice from integration settings
github-actions[bot] Sep 15, 2026
2696fb0
fix(web): voice panel transcript renders as ordered speaker-labeled chat
github-actions[bot] Sep 15, 2026
a5d41e1
fix(voice): continueThread no longer echoes a stale pre-dispatch sess…
github-actions[bot] Sep 15, 2026
7105443
feat(voice): discover and activate the attached UI's controls
github-actions[bot] Sep 15, 2026
fafd8cf
fix(voice): stop startThread defaulting to the discussed thread's pro…
github-actions[bot] Sep 15, 2026
12f0723
feat(web): durable recoverable voice session history
github-actions[bot] Sep 15, 2026
2e2862a
fix(voice): distinguish repeated controls by visible context and refu…
github-actions[bot] Sep 15, 2026
24f4939
test(voice): deterministic openThread loop for navigation latency audit
github-actions[bot] Sep 15, 2026
cbc8ab4
fix(voice): provenance-based missing-thread redirect reporting
github-actions[bot] Sep 15, 2026
91086d3
fix(voice): guard against provenance lost while a navigation was pending
github-actions[bot] Sep 15, 2026
eec3804
fix(voice): compare listed control context exactly, exclude only stru…
github-actions[bot] Sep 15, 2026
6270e16
fix(voice): record the real flat clickControl outcome in session history
github-actions[bot] Sep 15, 2026
9cfee0a
feat(voice): minimized draggable overlay with silence auto-collapse a…
github-actions[bot] Sep 15, 2026
9eb8946
test(voice): make overlay preference storage tests environment-indepe…
github-actions[bot] Sep 15, 2026
b23c806
fix(voice): address review findings on broker and client lifecycle
github-actions[bot] Sep 19, 2026
118e823
style(voice): apply vp fmt after rebase onto main
github-actions[bot] Sep 24, 2026
4ce2cfc
fix(voice): restore EnvironmentConnectionState dropped by main's dead…
github-actions[bot] Sep 24, 2026
12451b8
fix(voice): drop unnecessary exports flagged by knip's unused-exports…
github-actions[bot] Sep 24, 2026
16deb9b
fix(web): keep act() alive under fake timers after this PR's jsdom de…
github-actions[bot] Sep 25, 2026
7120d35
fix(server): validate voice credentials and align broker conventions
github-actions[bot] Sep 26, 2026
b07be6c
fix(web): preserve voice history and handle unavailable storage
github-actions[bot] Sep 26, 2026
4bfa0a0
fix(web): use shared voice controls and improve history actions
github-actions[bot] Sep 26, 2026
17e4537
fix(web): recover voice preferences after transient storage failures
github-actions[bot] Sep 26, 2026
1ca3dc5
fix(server): bound public voice errors and release upstream responses
github-actions[bot] Sep 26, 2026
db04dac
fix(voice): recover overlay state and preserve session outcomes
github-actions[bot] Sep 27, 2026
e850796
refactor(voice): align broker exports and shared overlay control
github-actions[bot] Sep 27, 2026
62aa567
fix(server): mark broker constructor as public service API
github-actions[bot] Sep 27, 2026
e60c3e3
fix(voice): preserve broker failure context
github-actions[bot] Sep 27, 2026
74206c3
fix(voice): keep session identifiers out of public errors
github-actions[bot] Sep 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions apps/server/src/environment/ServerEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
RELAY_URL_SECRET,
} from "../cloud/config.ts";
import * as ServerConfig from "../config.ts";
import { OPENAI_API_KEY_SECRET_NAME } from "../voice/broker.ts";
import * as ServerEnvironment from "./ServerEnvironment.ts";

const isServerEnvironmentIdPersistenceError = Schema.is(
Expand Down Expand Up @@ -229,6 +230,49 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
}),
);

it.effect("advertises voiceLive only while the OpenAI key secret is configured", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const baseDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-server-environment-voicelive-test-",
});
const testLayer = Layer.mergeAll(
ServerEnvironment.layer.pipe(Layer.provide(ServerSecretStore.layer)),
ServerSecretStore.layer,
).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir)));

yield* Effect.gen(function* () {
const secrets = yield* ServerSecretStore.ServerSecretStore;
const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
const encode = (value: string) => new TextEncoder().encode(value);

// Without the environment-owned OpenAI key the environment cannot
// broker a Live session, so the capability stays absent (absent means
// unsupported to clients) instead of advertising an entry point that
// can only fail.
const withoutKey = yield* serverEnvironment.getDescriptor;
expect(withoutKey.capabilities.voiceLive).toBeUndefined();

for (const emptyKey of ["", " \n\t"]) {
yield* secrets.set(OPENAI_API_KEY_SECRET_NAME, encode(emptyKey));
const empty = yield* serverEnvironment.getDescriptor;
expect(empty.capabilities.voiceLive).toBeUndefined();
}

// Configuring the key secret publishes the capability...
yield* secrets.set(OPENAI_API_KEY_SECRET_NAME, encode("test-key"));
const withKey = yield* serverEnvironment.getDescriptor;
expect(withKey.capabilities.voiceLive).toBe(true);

// ...and removing it withdraws the capability without a restart,
// matching the broker's per-mint key read.
yield* secrets.remove(OPENAI_API_KEY_SECRET_NAME);
const afterRemoval = yield* serverEnvironment.getDescriptor;
expect(afterRemoval.capabilities.voiceLive).toBeUndefined();
}).pipe(Effect.provide(testLayer));
}),
);

it.effect("advertises desktopAppUpdate only with desktop mode and the control fd", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down
33 changes: 26 additions & 7 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as Crypto from "effect/Crypto";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";

Expand All @@ -20,6 +21,7 @@ import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts";
import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts";
import * as ServerConfig from "../config.ts";
import * as ProcessRunner from "../processRunner.ts";
import { OPENAI_API_KEY_SECRET_NAME } from "../voice/broker.ts";
import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";
import { detectServerEnvironmentMachineKind } from "./ServerEnvironmentMachine.ts";

Expand Down Expand Up @@ -255,15 +257,32 @@ export const make = Effect.gen(function* () {

return ServerEnvironment.of({
getEnvironmentId: Effect.succeed(environmentId),
// The publish opt-in and relay link change at runtime (`t3 connect
// publish`, the client settings toggle), so the capability is read per
// The publish opt-in, relay link, and voice brokering eligibility change
// at runtime (`t3 connect publish`, the client settings toggle, and the
// environment-owned OpenAI key secret), so the capabilities are read per
// descriptor request rather than baked in at startup.
getDescriptor: readAgentActivityPublishingActive(secrets).pipe(
Effect.map((agentActivityPublishing) => ({
getDescriptor: Effect.gen(function* () {
const agentActivityPublishing = yield* readAgentActivityPublishingActive(secrets);
const openAiKey = yield* secrets
.get(OPENAI_API_KEY_SECRET_NAME)
.pipe(
Effect.catch((cause) =>
Effect.logWarning("failed to read the OpenAI API key secret", { cause }).pipe(
Effect.as(Option.none<Uint8Array>()),
),
),
);
return {
...descriptor,
capabilities: { ...descriptor.capabilities, agentActivityPublishing },
})),
),
capabilities: {
...descriptor.capabilities,
agentActivityPublishing,
...(Option.isSome(openAiKey) && new TextDecoder().decode(openAiKey.value).trim()
? { voiceLive: true }
: {}),
},
};
}),
});
});

Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts";
import { fixPath } from "./os-jank.ts";
import { websocketRpcRouteLayer } from "./ws.ts";
import * as VoiceBroker from "./voice/broker.ts";
import * as ExternalLauncher from "./process/externalLauncher.ts";
import * as NodePtyAdapter from "./terminal/NodePtyAdapter.ts";
import { pullRequestHttpApiLayer } from "./pullRequest/http.ts";
Expand Down Expand Up @@ -602,6 +603,7 @@ export const makeRoutesLayer = Layer.mergeAll(
assetRouteLayer,
attachmentUploadRouteLayer,
deviceHubProxyRouteLayer,
VoiceBroker.voiceBrokerRouteLayer,
staticAndDevRouteLayer,
websocketRpcRouteLayer,
),
Expand Down
Loading
Loading